Tahinli/forge
public🎉 feat: initial commit of forge
3c314e0321f2064e63dd7e065978558dd286b696
Author Ahmet Kaan Gümüş ·
Root commit (no parents)
300 changed files +119653 -0
Showing the first 300 files; this commit changes more.
- .gitignore +12 -0
- Cargo.lock +8717 -0
- Cargo.toml +124 -0
- LICENSE +661 -0
- README.md +299 -0
- config/forge.example.toml +112 -0
- crates/forge-ci/Cargo.toml +15 -0
- crates/forge-ci/src/config.rs +662 -0
- crates/forge-ci/src/error.rs +60 -0
- crates/forge-ci/src/lib.rs +9 -0
- crates/forge-ci/src/mask.rs +256 -0
- crates/forge-ci/src/proto.rs +173 -0
- crates/forge-core/Cargo.toml +36 -0
- crates/forge-core/migrations/0001_init.sql +92 -0
- crates/forge-core/migrations/0002_profile.sql +12 -0
- crates/forge-core/migrations/0003_branch_protection.sql +12 -0
- crates/forge-core/migrations/0004_issues.sql +61 -0
- crates/forge-core/migrations/0005_stars.sql +9 -0
- crates/forge-core/migrations/0006_totp.sql +15 -0
- crates/forge-core/migrations/0007_pulls.sql +21 -0
- crates/forge-core/migrations/0008_reviews.sql +24 -0
- crates/forge-core/migrations/0009_merge_rules.sql +9 -0
- crates/forge-core/migrations/0010_audit_log.sql +21 -0
- crates/forge-core/migrations/0011_webhooks.sql +44 -0
- crates/forge-core/migrations/0012_notifications.sql +12 -0
- crates/forge-core/migrations/0013_commit_status.sql +20 -0
- crates/forge-core/migrations/0014_backup_runs.sql +19 -0
- crates/forge-core/migrations/0015_reactions.sql +14 -0
- crates/forge-core/migrations/0016_review_remap.sql +3 -0
- crates/forge-core/migrations/0017_mirrors.sql +26 -0
- crates/forge-core/migrations/0018_issue_lock.sql +6 -0
- crates/forge-core/migrations/0019_pinned_issues.sql +11 -0
- crates/forge-core/migrations/0020_releases.sql +32 -0
- crates/forge-core/migrations/0021_lfs.sql +12 -0
- crates/forge-core/migrations/0022_signatures.sql +31 -0
- crates/forge-core/migrations/0023_replication.sql +13 -0
- crates/forge-core/migrations/0024_ci.sql +77 -0
- crates/forge-core/migrations/0025_orgs.sql +22 -0
- crates/forge-core/migrations/0026_mail.sql +36 -0
- crates/forge-core/migrations/0027_wiki.sql +4 -0
- crates/forge-core/migrations/0028_discussions.sql +43 -0
- crates/forge-core/migrations/0029_snippets.sql +29 -0
- crates/forge-core/migrations/0030_ui_prefs.sql +5 -0
- crates/forge-core/migrations/0031_advisories.sql +35 -0
- crates/forge-core/migrations/0032_repo_transfers.sql +11 -0
- crates/forge-core/migrations/0033_account_tokens.sql +33 -0
- crates/forge-core/migrations/0034_advisory_notifications.sql +12 -0
- crates/forge-core/migrations/0035_email_notifications.sql +6 -0
- crates/forge-core/src/advisories.rs +594 -0
- crates/forge-core/src/audit.rs +413 -0
- crates/forge-core/src/authz.rs +569 -0
- crates/forge-core/src/avatar.rs +182 -0
- crates/forge-core/src/ci.rs +185 -0
- crates/forge-core/src/config.rs +570 -0
- crates/forge-core/src/discussions.rs +158 -0
- crates/forge-core/src/error.rs +29 -0
- crates/forge-core/src/issue_refs.rs +219 -0
- crates/forge-core/src/issues.rs +369 -0
- crates/forge-core/src/lfs_token.rs +216 -0
- crates/forge-core/src/lib.rs +46 -0
- crates/forge-core/src/mail.rs +366 -0
- crates/forge-core/src/mirrors.rs +86 -0
- crates/forge-core/src/model.rs +255 -0
- crates/forge-core/src/names.rs +252 -0
- crates/forge-core/src/notifications.rs +137 -0
- crates/forge-core/src/password.rs +52 -0
- crates/forge-core/src/protection.rs +373 -0
- crates/forge-core/src/pulls.rs +128 -0
- crates/forge-core/src/raw_token.rs +142 -0
- crates/forge-core/src/reactions.rs +112 -0
- crates/forge-core/src/releases.rs +112 -0
- crates/forge-core/src/reviews.rs +123 -0
- crates/forge-core/src/secret.rs +178 -0
- crates/forge-core/src/session.rs +32 -0
- crates/forge-core/src/store/account_tokens.rs +537 -0
- crates/forge-core/src/store/advisories.rs +788 -0
- crates/forge-core/src/store/audit.rs +119 -0
- crates/forge-core/src/store/backup_runs.rs +349 -0
- crates/forge-core/src/store/ci.rs +2013 -0
- crates/forge-core/src/store/commit_status.rs +529 -0
- crates/forge-core/src/store/contributions.rs +146 -0
- crates/forge-core/src/store/discussions.rs +1226 -0
- crates/forge-core/src/store/edit_history_read.rs +104 -0
- crates/forge-core/src/store/issues.rs +1730 -0
- crates/forge-core/src/store/labels.rs +1338 -0
- crates/forge-core/src/store/lfs.rs +97 -0
- crates/forge-core/src/store/mail.rs +541 -0
- crates/forge-core/src/store/migrate.rs +356 -0
- crates/forge-core/src/store/mirrors.rs +691 -0
- crates/forge-core/src/store/mod.rs +2456 -0
- crates/forge-core/src/store/notifications.rs +923 -0
- crates/forge-core/src/store/notifications_extra.rs +45 -0
- crates/forge-core/src/store/orgs.rs +1125 -0
- crates/forge-core/src/store/pinned.rs +227 -0
- crates/forge-core/src/store/pulls.rs +1047 -0
- crates/forge-core/src/store/pulls_list_extra.rs +32 -0
- crates/forge-core/src/store/push_recent.rs +191 -0
- crates/forge-core/src/store/reactions.rs +378 -0
- crates/forge-core/src/store/releases.rs +461 -0
- crates/forge-core/src/store/replication.rs +203 -0
- crates/forge-core/src/store/reviews.rs +1646 -0
- crates/forge-core/src/store/signatures.rs +418 -0
- crates/forge-core/src/store/snapshot.rs +281 -0
- crates/forge-core/src/store/snippets.rs +590 -0
- crates/forge-core/src/store/transfers.rs +491 -0
- crates/forge-core/src/store/ui_prefs.rs +116 -0
- crates/forge-core/src/store/user_delete.rs +692 -0
- crates/forge-core/src/store/webhooks.rs +1260 -0
- crates/forge-core/src/store/wiki.rs +80 -0
- crates/forge-core/src/time.rs +81 -0
- crates/forge-core/src/token.rs +75 -0
- crates/forge-core/src/totp.rs +258 -0
- crates/forge-core/src/tz.rs +93 -0
- crates/forge-core/src/webhooks.rs +150 -0
- crates/forge-core/tests/audit_store.rs +161 -0
- crates/forge-core/tests/read_pool.rs +149 -0
- crates/forge-core/tests/stars_store.rs +246 -0
- crates/forge-core/tests/store_basic.rs +542 -0
- crates/forge-git/Cargo.toml +19 -0
- crates/forge-git/src/backup.rs +242 -0
- crates/forge-git/src/browse.rs +441 -0
- crates/forge-git/src/command.rs +322 -0
- crates/forge-git/src/error.rs +26 -0
- crates/forge-git/src/fsck.rs +89 -0
- crates/forge-git/src/history.rs +746 -0
- crates/forge-git/src/lib.rs +24 -0
- crates/forge-git/src/maintenance.rs +112 -0
- crates/forge-git/src/mirror.rs +733 -0
- crates/forge-git/src/pktline.rs +23 -0
- crates/forge-git/src/pulls.rs +1075 -0
- crates/forge-git/src/repo.rs +488 -0
- crates/forge-git/src/search.rs +430 -0
- crates/forge-git/src/service.rs +64 -0
- crates/forge-git/src/setup.rs +126 -0
- crates/forge-git/src/tags.rs +67 -0
- crates/forge-git/src/verify.rs +535 -0
- crates/forge-git/src/version.rs +47 -0
- crates/forge-git/src/wiki.rs +370 -0
- crates/forge-git/tests/browse.rs +318 -0
- crates/forge-git/tests/forge_git.rs +136 -0
- crates/forge-git/tests/history.rs +387 -0
- crates/forge-runner/Cargo.toml +46 -0
- crates/forge-runner/src/artifacts.rs +274 -0
- crates/forge-runner/src/check.rs +517 -0
- crates/forge-runner/src/cli.rs +324 -0
- crates/forge-runner/src/client.rs +226 -0
- crates/forge-runner/src/config.rs +408 -0
- crates/forge-runner/src/executor.rs +913 -0
- crates/forge-runner/src/lib.rs +17 -0
- crates/forge-runner/src/logs.rs +432 -0
- crates/forge-runner/src/main.rs +13 -0
- crates/forge-runner/src/oci/helper.rs +86 -0
- crates/forge-runner/src/oci/mod.rs +74 -0
- crates/forge-runner/src/oci/pull.rs +445 -0
- crates/forge-runner/src/oci/store.rs +480 -0
- crates/forge-runner/src/oci/unpack.rs +889 -0
- crates/forge-runner/src/runner.rs +323 -0
- crates/forge-runner/src/sandbox/exec.rs +63 -0
- crates/forge-runner/src/sandbox/helper.rs +527 -0
- crates/forge-runner/src/sandbox/idmap.rs +609 -0
- crates/forge-runner/src/sandbox/mod.rs +72 -0
- crates/forge-runner/src/sandbox/seccomp.rs +191 -0
- crates/forge-runner/src/sandbox/spec.rs +754 -0
- crates/forge-runner/src/workspace.rs +239 -0
- crates/forge-runner/tests/builtin.rs +322 -0
- crates/forge-runner/tests/flow.rs +548 -0
- crates/forge-runner/tests/idmap.rs +54 -0
- crates/forge-runner/tests/sandbox.rs +505 -0
- crates/forge-runner/tests/unpack_subuid.rs +136 -0
- crates/forge-ssh/Cargo.toml +19 -0
- crates/forge-ssh/src/backend.rs +37 -0
- crates/forge-ssh/src/error.rs +11 -0
- crates/forge-ssh/src/lib.rs +11 -0
- crates/forge-ssh/src/parse.rs +428 -0
- crates/forge-ssh/src/server.rs +610 -0
- crates/forge-ssh/tests/ssh_e2e.rs +308 -0
- crates/forge-web/Cargo.toml +66 -0
- crates/forge-web/assets/nav.js +305 -0
- crates/forge-web/build.rs +39 -0
- crates/forge-web/src/access.rs +183 -0
- crates/forge-web/src/admin_cli.rs +763 -0
- crates/forge-web/src/admin_client.rs +231 -0
- crates/forge-web/src/advisories/mod.rs +6 -0
- crates/forge-web/src/advisories/scan.rs +150 -0
- crates/forge-web/src/advisories/worker.rs +191 -0
- crates/forge-web/src/api/mod.rs +268 -0
- crates/forge-web/src/api/statuses.rs +567 -0
- crates/forge-web/src/audit.rs +54 -0
- crates/forge-web/src/auth.rs +449 -0
- crates/forge-web/src/backup/create.rs +308 -0
- crates/forge-web/src/backup/fsck.rs +126 -0
- crates/forge-web/src/backup/healthcheck.rs +108 -0
- crates/forge-web/src/backup/mod.rs +264 -0
- crates/forge-web/src/backup/restore.rs +625 -0
- crates/forge-web/src/backup/retention.rs +161 -0
- crates/forge-web/src/backup/scheduler.rs +524 -0
- crates/forge-web/src/backup/tests.rs +456 -0
- crates/forge-web/src/backup/verify.rs +306 -0
- crates/forge-web/src/blobs.rs +143 -0
- crates/forge-web/src/boot.rs +280 -0
- crates/forge-web/src/ci/api.rs +639 -0
- crates/forge-web/src/ci/api_tests.rs +771 -0
- crates/forge-web/src/ci/mod.rs +197 -0
- crates/forge-web/src/ci/reaper.rs +124 -0
- crates/forge-web/src/ci/schedule.rs +892 -0
- crates/forge-web/src/client_ip.rs +156 -0
- crates/forge-web/src/compression.rs +288 -0
- crates/forge-web/src/git_auth.rs +175 -0
- crates/forge-web/src/git_http.rs +608 -0
- crates/forge-web/src/hook_cmd.rs +236 -0
- crates/forge-web/src/i18n/admin.rs +819 -0
- crates/forge-web/src/i18n/code.rs +624 -0
- crates/forge-web/src/i18n/community.rs +575 -0
- crates/forge-web/src/i18n/integrations.rs +660 -0
- crates/forge-web/src/i18n/issues.rs +1039 -0
- crates/forge-web/src/i18n/misc.rs +776 -0
- crates/forge-web/src/i18n/mod.rs +1267 -0
- crates/forge-web/src/i18n/orgs.rs +617 -0
- crates/forge-web/src/i18n/pulls.rs +380 -0
- crates/forge-web/src/i18n/pulls_review.rs +441 -0
- crates/forge-web/src/i18n/releases_ci.rs +565 -0
- crates/forge-web/src/i18n/repo_admin.rs +783 -0
- crates/forge-web/src/internal.rs +1082 -0
- crates/forge-web/src/lfs/gc.rs +247 -0
- crates/forge-web/src/lfs/mod.rs +82 -0
- crates/forge-web/src/lfs/route_tests.rs +626 -0
- crates/forge-web/src/lfs/routes.rs +512 -0
- crates/forge-web/src/lfs_pointer.rs +148 -0
- crates/forge-web/src/lib.rs +1 -0
- crates/forge-web/src/mail/account.rs +71 -0
- crates/forge-web/src/mail/log.rs +44 -0
- crates/forge-web/src/mail/mod.rs +109 -0
- crates/forge-web/src/mail/smtp.rs +161 -0
- crates/forge-web/src/mail/templates.rs +244 -0
- crates/forge-web/src/mail/worker.rs +266 -0
- crates/forge-web/src/main.rs +218 -0
- crates/forge-web/src/mirrors/mod.rs +457 -0
- crates/forge-web/src/mirrors/worker.rs +253 -0
- crates/forge-web/src/notify.rs +1189 -0
- crates/forge-web/src/pages/admin_backups.rs +551 -0
- crates/forge-web/src/pages/admin_mail.rs +588 -0
- crates/forge-web/src/pages/admin_replication.rs +544 -0
- crates/forge-web/src/pages/admin_runners.rs +564 -0
- crates/forge-web/src/pages/admin_users.rs +400 -0
- crates/forge-web/src/pages/archive.rs +192 -0
- crates/forge-web/src/pages/assets.rs +294 -0
- crates/forge-web/src/pages/audit_log.rs +448 -0
- crates/forge-web/src/pages/avatars.rs +161 -0
- crates/forge-web/src/pages/blob.rs +300 -0
- crates/forge-web/src/pages/ci_job.rs +390 -0
- crates/forge-web/src/pages/ci_runs.rs +799 -0
- crates/forge-web/src/pages/ci_tests.rs +581 -0
- crates/forge-web/src/pages/code_search.rs +466 -0
- crates/forge-web/src/pages/commit.rs +345 -0
- crates/forge-web/src/pages/commits.rs +685 -0
- crates/forge-web/src/pages/compare.rs +1013 -0
- crates/forge-web/src/pages/dashboard.rs +216 -0
- crates/forge-web/src/pages/discussion_actions.rs +389 -0
- crates/forge-web/src/pages/discussion_settings.rs +93 -0
- crates/forge-web/src/pages/discussion_view.rs +484 -0
- crates/forge-web/src/pages/discussions.rs +527 -0
- crates/forge-web/src/pages/edit_history.rs +607 -0
- crates/forge-web/src/pages/explore.rs +264 -0
- crates/forge-web/src/pages/forgot.rs +384 -0
- crates/forge-web/src/pages/fork.rs +529 -0
- crates/forge-web/src/pages/invites.rs +147 -0
- crates/forge-web/src/pages/issue_meta.rs +421 -0
- crates/forge-web/src/pages/issue_meta_tests.rs +769 -0
- crates/forge-web/src/pages/issue_parts.rs +1331 -0
- crates/forge-web/src/pages/issue_pin.rs +110 -0
- crates/forge-web/src/pages/issue_pin_tests.rs +342 -0
- crates/forge-web/src/pages/issue_view.rs +1027 -0
- crates/forge-web/src/pages/issues_list.rs +1347 -0
- crates/forge-web/src/pages/labels_css.rs +242 -0
- crates/forge-web/src/pages/labels_page.rs +459 -0
- crates/forge-web/src/pages/login.rs +888 -0
- crates/forge-web/src/pages/media.rs +465 -0
- crates/forge-web/src/pages/milestones_page.rs +453 -0
- crates/forge-web/src/pages/mod.rs +95 -0
- crates/forge-web/src/pages/new_repo.rs +610 -0
- crates/forge-web/src/pages/notifications.rs +1377 -0
- crates/forge-web/src/pages/org_ctx.rs +255 -0
- crates/forge-web/src/pages/org_members.rs +355 -0
- crates/forge-web/src/pages/org_new.rs +151 -0
- crates/forge-web/src/pages/org_settings.rs +224 -0
- crates/forge-web/src/pages/org_teams.rs +779 -0
- crates/forge-web/src/pages/profile.rs +382 -0
- crates/forge-web/src/pages/pull_branch.rs +767 -0
- crates/forge-web/src/pages/pull_files.rs +589 -0
- crates/forge-web/src/pages/pull_reviews.rs +1180 -0
- crates/forge-web/src/pages/pull_view.rs +1607 -0
- crates/forge-web/src/pages/pulls_list.rs +532 -0
- crates/forge-web/src/pages/pulls_merge.rs +1231 -0
- crates/forge-web/src/pages/reactions.rs +90 -0
- crates/forge-web/src/pages/reactions_tests.rs +344 -0
- crates/forge-web/src/pages/refs.rs +209 -0
- crates/forge-web/src/pages/register.rs +683 -0
- crates/forge-web/src/pages/releases.rs +1308 -0
- crates/forge-web/src/pages/releases_tests.rs +590 -0
- crates/forge-web/src/pages/repo.rs +987 -0
.gitignore| @@ -0,0 +1,12 @@ | ||
| 1 | +/target | |
| 2 | +/data | |
| 3 | +*.db | |
| 4 | +*.db-* | |
| 5 | +forge.key | |
| 6 | +ssh_host_ed25519_key | |
| 7 | +.env | |
| 8 | +*.pem | |
| 9 | +.env.* | |
| 10 | + | |
| 11 | +# local config; copy config/forge.example.toml | |
| 12 | +/config/forge.toml |
Cargo.lockDiff too large; view file
| @@ -0,0 +1,8717 @@ | ||
| 1 | +# This file is automatically @generated by Cargo. | |
| 2 | +# It is not intended for manual editing. | |
| 3 | +version = 4 | |
| 4 | + | |
| 5 | +[[package]] | |
| 6 | +name = "adler2" | |
| 7 | +version = "2.0.1" | |
| 8 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 9 | +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" | |
| 10 | + | |
| 11 | +[[package]] | |
| 12 | +name = "aead" | |
| 13 | +version = "0.5.2" | |
| 14 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 15 | +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" | |
| 16 | +dependencies = [ | |
| 17 | + "crypto-common 0.1.7", | |
| 18 | + "generic-array 0.14.7", | |
| 19 | +] | |
| 20 | + | |
| 21 | +[[package]] | |
| 22 | +name = "aead" | |
| 23 | +version = "0.6.1" | |
| 24 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 25 | +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" | |
| 26 | +dependencies = [ | |
| 27 | + "crypto-common 0.2.2", | |
| 28 | + "inout 0.2.3", | |
| 29 | +] | |
| 30 | + | |
| 31 | +[[package]] | |
| 32 | +name = "aegis" | |
| 33 | +version = "0.9.20" | |
| 34 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 35 | +checksum = "41585f248ed9406567ae6d61ca2a55ed3dc7348e65203a01229881253acb95e3" | |
| 36 | +dependencies = [ | |
| 37 | + "cc", | |
| 38 | + "softaes", | |
| 39 | +] | |
| 40 | + | |
| 41 | +[[package]] | |
| 42 | +name = "aes" | |
| 43 | +version = "0.8.4" | |
| 44 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 45 | +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" | |
| 46 | +dependencies = [ | |
| 47 | + "cfg-if", | |
| 48 | + "cipher 0.4.4", | |
| 49 | + "cpufeatures 0.2.17", | |
| 50 | +] | |
| 51 | + | |
| 52 | +[[package]] | |
| 53 | +name = "aes" | |
| 54 | +version = "0.9.3" | |
| 55 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 56 | +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" | |
| 57 | +dependencies = [ | |
| 58 | + "cipher 0.5.2", | |
| 59 | + "cpubits", | |
| 60 | + "cpufeatures 0.3.1", | |
| 61 | + "zeroize", | |
| 62 | +] | |
| 63 | + | |
| 64 | +[[package]] | |
| 65 | +name = "aes-gcm" | |
| 66 | +version = "0.10.3" | |
| 67 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 68 | +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" | |
| 69 | +dependencies = [ | |
| 70 | + "aead 0.5.2", | |
| 71 | + "aes 0.8.4", | |
| 72 | + "cipher 0.4.4", | |
| 73 | + "ctr 0.9.2", | |
| 74 | + "ghash 0.5.1", | |
| 75 | + "subtle", | |
| 76 | +] | |
| 77 | + | |
| 78 | +[[package]] | |
| 79 | +name = "aes-gcm" | |
| 80 | +version = "0.11.1" | |
| 81 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 82 | +checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f" | |
| 83 | +dependencies = [ | |
| 84 | + "aead 0.6.1", | |
| 85 | + "aes 0.9.3", | |
| 86 | + "cipher 0.5.2", | |
| 87 | + "ctr 0.10.1", | |
| 88 | + "ctutils", | |
| 89 | + "ghash 0.6.0", | |
| 90 | + "zeroize", | |
| 91 | +] | |
| 92 | + | |
| 93 | +[[package]] | |
| 94 | +name = "ahash" | |
| 95 | +version = "0.8.12" | |
| 96 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 97 | +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" | |
| 98 | +dependencies = [ | |
| 99 | + "cfg-if", | |
| 100 | + "once_cell", | |
| 101 | + "version_check", | |
| 102 | + "zerocopy", | |
| 103 | +] | |
| 104 | + | |
| 105 | +[[package]] | |
| 106 | +name = "aho-corasick" | |
| 107 | +version = "1.1.5" | |
| 108 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 109 | +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" | |
| 110 | +dependencies = [ | |
| 111 | + "memchr", | |
| 112 | +] | |
| 113 | + | |
| 114 | +[[package]] | |
| 115 | +name = "alloc-no-stdlib" | |
| 116 | +version = "3.0.0" | |
| 117 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 118 | +checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a" | |
| 119 | + | |
| 120 | +[[package]] | |
| 121 | +name = "alloc-stdlib" | |
| 122 | +version = "0.3.0" | |
| 123 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 124 | +checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1" | |
| 125 | +dependencies = [ | |
| 126 | + "alloc-no-stdlib", | |
| 127 | +] | |
| 128 | + | |
| 129 | +[[package]] | |
| 130 | +name = "allocator-api2" | |
| 131 | +version = "0.2.21" | |
| 132 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 133 | +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" | |
| 134 | + | |
| 135 | +[[package]] | |
| 136 | +name = "allocator-api2" | |
| 137 | +version = "0.4.0" | |
| 138 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 139 | +checksum = "c880a97d28a3681c0267bd29cff89621202715b065127cd445fa0f0fe0aa2880" | |
| 140 | + | |
| 141 | +[[package]] | |
| 142 | +name = "ammonia" | |
| 143 | +version = "4.2.3" | |
| 144 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 145 | +checksum = "061e83b03c2681c18a6787d956e355c74e0b98ba7ba3d69b0822ade1e6f1d716" | |
| 146 | +dependencies = [ | |
| 147 | + "cssparser 0.38.0", | |
| 148 | + "html5ever 0.40.1", | |
| 149 | + "maplit", | |
| 150 | + "url", | |
| 151 | +] | |
| 152 | + | |
| 153 | +[[package]] | |
| 154 | +name = "android_system_properties" | |
| 155 | +version = "0.1.6" | |
| 156 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 157 | +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" | |
| 158 | +dependencies = [ | |
| 159 | + "libc", | |
| 160 | +] | |
| 161 | + | |
| 162 | +[[package]] | |
| 163 | +name = "anstream" | |
| 164 | +version = "1.0.0" | |
| 165 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 166 | +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" | |
| 167 | +dependencies = [ | |
| 168 | + "anstyle", | |
| 169 | + "anstyle-parse", | |
| 170 | + "anstyle-query", | |
| 171 | + "anstyle-wincon", | |
| 172 | + "colorchoice", | |
| 173 | + "is_terminal_polyfill", | |
| 174 | + "utf8parse", | |
| 175 | +] | |
| 176 | + | |
| 177 | +[[package]] | |
| 178 | +name = "anstyle" | |
| 179 | +version = "1.0.14" | |
| 180 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 181 | +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" | |
| 182 | + | |
| 183 | +[[package]] | |
| 184 | +name = "anstyle-parse" | |
| 185 | +version = "1.0.0" | |
| 186 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 187 | +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" | |
| 188 | +dependencies = [ | |
| 189 | + "utf8parse", | |
| 190 | +] | |
| 191 | + | |
| 192 | +[[package]] | |
| 193 | +name = "anstyle-query" | |
| 194 | +version = "1.1.5" | |
| 195 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 196 | +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" | |
| 197 | +dependencies = [ | |
| 198 | + "windows-sys 0.61.2", | |
| 199 | +] | |
| 200 | + | |
| 201 | +[[package]] | |
| 202 | +name = "anstyle-wincon" | |
| 203 | +version = "3.0.11" | |
| 204 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 205 | +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" | |
| 206 | +dependencies = [ | |
| 207 | + "anstyle", | |
| 208 | + "once_cell_polyfill", | |
| 209 | + "windows-sys 0.61.2", | |
| 210 | +] | |
| 211 | + | |
| 212 | +[[package]] | |
| 213 | +name = "antithesis_sdk" | |
| 214 | +version = "0.3.0" | |
| 215 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 216 | +checksum = "a8ed6751597568a284314fd9e2db420665898600109dedc7d8d9b588f00ae9c4" | |
| 217 | +dependencies = [ | |
| 218 | + "libc", | |
| 219 | + "libloading", | |
| 220 | + "linkme", | |
| 221 | + "once_cell", | |
| 222 | + "rand 0.8.8", | |
| 223 | + "rustc_version_runtime", | |
| 224 | + "serde", | |
| 225 | + "serde_json", | |
| 226 | +] | |
| 227 | + | |
| 228 | +[[package]] | |
| 229 | +name = "anyhow" | |
| 230 | +version = "1.0.104" | |
| 231 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 232 | +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" | |
| 233 | + | |
| 234 | +[[package]] | |
| 235 | +name = "anymap3" | |
| 236 | +version = "1.1.0" | |
| 237 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 238 | +checksum = "fb5dfbc6d8d2675589ccbe4d0fd61df2419075625f8c1a62325e718e2b0049f9" | |
| 239 | + | |
| 240 | +[[package]] | |
| 241 | +name = "arc-swap" | |
| 242 | +version = "1.9.2" | |
| 243 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 244 | +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" | |
| 245 | +dependencies = [ | |
| 246 | + "rustversion", | |
| 247 | +] | |
| 248 | + | |
| 249 | +[[package]] | |
| 250 | +name = "argon2" | |
| 251 | +version = "0.6.0" | |
| 252 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 253 | +checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c" | |
| 254 | +dependencies = [ | |
| 255 | + "base64ct", | |
| 256 | + "blake2", | |
| 257 | + "cpufeatures 0.3.1", | |
| 258 | + "password-hash", | |
| 259 | +] | |
| 260 | + | |
| 261 | +[[package]] | |
| 262 | +name = "aristo" | |
| 263 | +version = "0.4.1" | |
| 264 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 265 | +checksum = "fcdeefa800110050103e2459a2f8dbdbd560ad046e30f1f87e24ce19c2cb8bde" | |
| 266 | +dependencies = [ | |
| 267 | + "aristo-macros", | |
| 268 | +] | |
| 269 | + | |
| 270 | +[[package]] | |
| 271 | +name = "aristo-macros" | |
| 272 | +version = "0.4.1" | |
| 273 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 274 | +checksum = "d64a66d21a80182b35b1741997a6d2456911f54b7eb1918aa4e2382fc205268d" | |
| 275 | +dependencies = [ | |
| 276 | + "proc-macro2", | |
| 277 | + "quote", | |
| 278 | + "syn 2.0.119", | |
| 279 | +] | |
| 280 | + | |
| 281 | +[[package]] | |
| 282 | +name = "arrayvec" | |
| 283 | +version = "0.7.8" | |
| 284 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 285 | +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" | |
| 286 | + | |
| 287 | +[[package]] | |
| 288 | +name = "assoc" | |
| 289 | +version = "0.1.3" | |
| 290 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 291 | +checksum = "bfdc70193dadb9d7287fa4b633f15f90c876915b31f6af17da307fc59c9859a8" | |
| 292 | + | |
| 293 | +[[package]] | |
| 294 | +name = "async-compression" | |
| 295 | +version = "0.4.50" | |
| 296 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 297 | +checksum = "ee19bd99b43e3691acbad4e840420a4881cea6c0b66a208125a824f8fd53f5a1" | |
| 298 | +dependencies = [ | |
| 299 | + "compression-codecs", | |
| 300 | + "compression-core", | |
| 301 | + "pin-project-lite", | |
| 302 | + "tokio", | |
| 303 | +] | |
| 304 | + | |
| 305 | +[[package]] | |
| 306 | +name = "async-trait" | |
| 307 | +version = "0.1.92" | |
| 308 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 309 | +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" | |
| 310 | +dependencies = [ | |
| 311 | + "proc-macro2", | |
| 312 | + "quote", | |
| 313 | + "syn 3.0.9", | |
| 314 | +] | |
| 315 | + | |
| 316 | +[[package]] | |
| 317 | +name = "atomic-waker" | |
| 318 | +version = "1.1.2" | |
| 319 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 320 | +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" | |
| 321 | + | |
| 322 | +[[package]] | |
| 323 | +name = "autocfg" | |
| 324 | +version = "1.5.1" | |
| 325 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 326 | +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" | |
| 327 | + | |
| 328 | +[[package]] | |
| 329 | +name = "aws-lc-rs" | |
| 330 | +version = "1.18.2" | |
| 331 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 332 | +checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" | |
| 333 | +dependencies = [ | |
| 334 | + "aws-lc-sys", | |
| 335 | + "untrusted 0.7.1", | |
| 336 | + "zeroize", | |
| 337 | +] | |
| 338 | + | |
| 339 | +[[package]] | |
| 340 | +name = "aws-lc-sys" | |
| 341 | +version = "0.46.0" | |
| 342 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 343 | +checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" | |
| 344 | +dependencies = [ | |
| 345 | + "cc", | |
| 346 | + "cmake", | |
| 347 | + "dunce", | |
| 348 | + "fs_extra", | |
| 349 | + "pkg-config", | |
| 350 | +] | |
| 351 | + | |
| 352 | +[[package]] | |
| 353 | +name = "base16ct" | |
| 354 | +version = "1.0.0" | |
| 355 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 356 | +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" | |
| 357 | + | |
| 358 | +[[package]] | |
| 359 | +name = "base64" | |
| 360 | +version = "0.22.1" | |
| 361 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 362 | +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" | |
| 363 | + | |
| 364 | +[[package]] | |
| 365 | +name = "base64" | |
| 366 | +version = "0.23.1" | |
| 367 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 368 | +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" | |
| 369 | + | |
| 370 | +[[package]] | |
| 371 | +name = "base64ct" | |
| 372 | +version = "1.8.3" | |
| 373 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 374 | +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" | |
| 375 | + | |
| 376 | +[[package]] | |
| 377 | +name = "bcrypt-pbkdf" | |
| 378 | +version = "0.11.0" | |
| 379 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 380 | +checksum = "144e573728da132683b9488acd528274c790e07fc06ff81ee29f9d8f8b1041e0" | |
| 381 | +dependencies = [ | |
| 382 | + "blowfish", | |
| 383 | + "pbkdf2", | |
| 384 | + "sha2 0.11.1", | |
| 385 | +] | |
| 386 | + | |
| 387 | +[[package]] | |
| 388 | +name = "bigdecimal" | |
| 389 | +version = "0.4.11" | |
| 390 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 391 | +checksum = "8fa3f3d8cbf4dffcfe4991de61d012bef509a409ecbe9dd41049bfe32b4d4653" | |
| 392 | +dependencies = [ | |
| 393 | + "autocfg", | |
| 394 | + "libm", | |
| 395 | + "num-bigint 0.4.8", | |
| 396 | + "num-integer", | |
| 397 | + "num-traits", | |
| 398 | +] | |
| 399 | + | |
| 400 | +[[package]] | |
| 401 | +name = "bincode" | |
| 402 | +version = "1.3.3" | |
| 403 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 404 | +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" | |
| 405 | +dependencies = [ | |
| 406 | + "serde", | |
| 407 | +] | |
| 408 | + | |
| 409 | +[[package]] | |
| 410 | +name = "bindgen" | |
| 411 | +version = "0.69.5" | |
| 412 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 413 | +checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" | |
| 414 | +dependencies = [ | |
| 415 | + "bitflags 2.13.2", | |
| 416 | + "cexpr", | |
| 417 | + "clang-sys", | |
| 418 | + "itertools 0.12.1", | |
| 419 | + "lazy_static", | |
| 420 | + "lazycell", | |
| 421 | + "log", | |
| 422 | + "prettyplease 0.2.37", | |
| 423 | + "proc-macro2", | |
| 424 | + "quote", | |
| 425 | + "regex", | |
| 426 | + "rustc-hash 1.1.0", | |
| 427 | + "shlex 1.3.0", | |
| 428 | + "syn 2.0.119", | |
| 429 | + "which", | |
| 430 | +] | |
| 431 | + | |
| 432 | +[[package]] | |
| 433 | +name = "bit-set" | |
| 434 | +version = "0.8.0" | |
| 435 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 436 | +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" | |
| 437 | +dependencies = [ | |
| 438 | + "bit-vec", | |
| 439 | +] | |
| 440 | + | |
| 441 | +[[package]] | |
| 442 | +name = "bit-vec" | |
| 443 | +version = "0.8.0" | |
| 444 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 445 | +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" | |
| 446 | + | |
| 447 | +[[package]] | |
| 448 | +name = "bitflags" | |
| 449 | +version = "1.3.2" | |
| 450 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 451 | +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" | |
| 452 | + | |
| 453 | +[[package]] | |
| 454 | +name = "bitflags" | |
| 455 | +version = "2.13.2" | |
| 456 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 457 | +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" | |
| 458 | + | |
| 459 | +[[package]] | |
| 460 | +name = "bitpacking" | |
| 461 | +version = "0.9.3" | |
| 462 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 463 | +checksum = "96a7139abd3d9cebf8cd6f920a389cf3dc9576172e32f4563f188cae3c3eb019" | |
| 464 | +dependencies = [ | |
| 465 | + "crunchy", | |
| 466 | +] | |
| 467 | + | |
| 468 | +[[package]] | |
| 469 | +name = "bitvec" | |
| 470 | +version = "1.1.1" | |
| 471 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 472 | +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" | |
| 473 | +dependencies = [ | |
| 474 | + "funty", | |
| 475 | + "radium", | |
| 476 | + "tap", | |
| 477 | + "wyz", | |
| 478 | +] | |
| 479 | + | |
| 480 | +[[package]] | |
| 481 | +name = "blake2" | |
| 482 | +version = "0.11.0" | |
| 483 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 484 | +checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" | |
| 485 | +dependencies = [ | |
| 486 | + "digest 0.11.3", | |
| 487 | +] | |
| 488 | + | |
| 489 | +[[package]] | |
| 490 | +name = "block-buffer" | |
| 491 | +version = "0.10.4" | |
| 492 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 493 | +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" | |
| 494 | +dependencies = [ | |
| 495 | + "generic-array 0.14.7", | |
| 496 | +] | |
| 497 | + | |
| 498 | +[[package]] | |
| 499 | +name = "block-buffer" | |
| 500 | +version = "0.12.1" | |
| 501 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 502 | +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" | |
| 503 | +dependencies = [ | |
| 504 | + "hybrid-array", | |
| 505 | + "zeroize", | |
| 506 | +] | |
| 507 | + | |
| 508 | +[[package]] | |
| 509 | +name = "block-padding" | |
| 510 | +version = "0.4.2" | |
| 511 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 512 | +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" | |
| 513 | +dependencies = [ | |
| 514 | + "hybrid-array", | |
| 515 | +] | |
| 516 | + | |
| 517 | +[[package]] | |
| 518 | +name = "blowfish" | |
| 519 | +version = "0.10.0" | |
| 520 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 521 | +checksum = "62ce3946557b35e71d1bbe07ec385073ce9eda05043f95de134eb578fcf1a298" | |
| 522 | +dependencies = [ | |
| 523 | + "byteorder", | |
| 524 | + "cipher 0.5.2", | |
| 525 | +] | |
| 526 | + | |
| 527 | +[[package]] | |
| 528 | +name = "bon" | |
| 529 | +version = "3.10.2" | |
| 530 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 531 | +checksum = "214f5df094ce551a10a30ffb9c70243d61f121a3d985a6495933e181dee6a7d2" | |
| 532 | +dependencies = [ | |
| 533 | + "bon-macros", | |
| 534 | +] | |
| 535 | + | |
| 536 | +[[package]] | |
| 537 | +name = "bon-macros" | |
| 538 | +version = "3.10.2" | |
| 539 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 540 | +checksum = "2706da6c749998cc555d04184909608956a69ad3f8ab1a076fbc867b4a3c3ce5" | |
| 541 | +dependencies = [ | |
| 542 | + "darling 0.24.1", | |
| 543 | + "ident_case", | |
| 544 | + "prettyplease 0.3.0", | |
| 545 | + "proc-macro2", | |
| 546 | + "quote", | |
| 547 | + "syn 3.0.9", | |
| 548 | +] | |
| 549 | + | |
| 550 | +[[package]] | |
| 551 | +name = "branches" | |
| 552 | +version = "0.4.6" | |
| 553 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 554 | +checksum = "7436b21fd6195415058eb41c9090e156b556bc7e0377bb57c5c026a421521525" | |
| 555 | +dependencies = [ | |
| 556 | + "rustc_version", | |
| 557 | +] | |
| 558 | + | |
| 559 | +[[package]] | |
| 560 | +name = "brotli" | |
| 561 | +version = "9.0.0" | |
| 562 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 563 | +checksum = "f8b851b75c23ca7873623d612fe49bd1989aeb03d08fb9432187eb253d3d4c6b" | |
| 564 | +dependencies = [ | |
| 565 | + "alloc-no-stdlib", | |
| 566 | + "alloc-stdlib", | |
| 567 | + "brotli-decompressor", | |
| 568 | +] | |
| 569 | + | |
| 570 | +[[package]] | |
| 571 | +name = "brotli-decompressor" | |
| 572 | +version = "6.0.1" | |
| 573 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 574 | +checksum = "941cd9bd4ddab83cb46fa5a2d428f1c857b24ac78cb876cf7beb710840934bd7" | |
| 575 | +dependencies = [ | |
| 576 | + "alloc-no-stdlib", | |
| 577 | + "alloc-stdlib", | |
| 578 | +] | |
| 579 | + | |
| 580 | +[[package]] | |
| 581 | +name = "bstr" | |
| 582 | +version = "1.13.1" | |
| 583 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 584 | +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" | |
| 585 | +dependencies = [ | |
| 586 | + "memchr", | |
| 587 | + "regex-automata", | |
| 588 | + "serde_core", | |
| 589 | +] | |
| 590 | + | |
| 591 | +[[package]] | |
| 592 | +name = "bumpalo" | |
| 593 | +version = "3.20.3" | |
| 594 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 595 | +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" | |
| 596 | + | |
| 597 | +[[package]] | |
| 598 | +name = "bytemuck" | |
| 599 | +version = "1.25.2" | |
| 600 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 601 | +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" | |
| 602 | +dependencies = [ | |
| 603 | + "bytemuck_derive", | |
| 604 | +] | |
| 605 | + | |
| 606 | +[[package]] | |
| 607 | +name = "bytemuck_derive" | |
| 608 | +version = "1.12.1" | |
| 609 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 610 | +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" | |
| 611 | +dependencies = [ | |
| 612 | + "proc-macro2", | |
| 613 | + "quote", | |
| 614 | + "syn 3.0.9", | |
| 615 | +] | |
| 616 | + | |
| 617 | +[[package]] | |
| 618 | +name = "byteorder" | |
| 619 | +version = "1.5.0" | |
| 620 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 621 | +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" | |
| 622 | + | |
| 623 | +[[package]] | |
| 624 | +name = "byteorder-lite" | |
| 625 | +version = "0.1.0" | |
| 626 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 627 | +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" | |
| 628 | + | |
| 629 | +[[package]] | |
| 630 | +name = "bytes" | |
| 631 | +version = "1.12.1" | |
| 632 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 633 | +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" | |
| 634 | + | |
| 635 | +[[package]] | |
| 636 | +name = "caps" | |
| 637 | +version = "0.5.6" | |
| 638 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 639 | +checksum = "fd1ddba47aba30b6a889298ad0109c3b8dcb0e8fc993b459daa7067d46f865e0" | |
| 640 | +dependencies = [ | |
| 641 | + "libc", | |
| 642 | +] | |
| 643 | + | |
| 644 | +[[package]] | |
| 645 | +name = "caseless" | |
| 646 | +version = "0.2.2" | |
| 647 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 648 | +checksum = "8b6fd507454086c8edfd769ca6ada439193cdb209c7681712ef6275cccbfe5d8" | |
| 649 | +dependencies = [ | |
| 650 | + "unicode-normalization", | |
| 651 | +] | |
| 652 | + | |
| 653 | +[[package]] | |
| 654 | +name = "cbc" | |
| 655 | +version = "0.2.1" | |
| 656 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 657 | +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" | |
| 658 | +dependencies = [ | |
| 659 | + "cipher 0.5.2", | |
| 660 | +] | |
| 661 | + | |
| 662 | +[[package]] | |
| 663 | +name = "cc" | |
| 664 | +version = "1.8.0" | |
| 665 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 666 | +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" | |
| 667 | +dependencies = [ | |
| 668 | + "find-msvc-tools", | |
| 669 | + "jobserver", | |
| 670 | + "libc", | |
| 671 | + "shlex 2.0.1", | |
| 672 | +] | |
| 673 | + | |
| 674 | +[[package]] | |
| 675 | +name = "census" | |
| 676 | +version = "0.4.2" | |
| 677 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 678 | +checksum = "4f4c707c6a209cbe82d10abd08e1ea8995e9ea937d2550646e02798948992be0" | |
| 679 | + | |
| 680 | +[[package]] | |
| 681 | +name = "cexpr" | |
| 682 | +version = "0.6.0" | |
| 683 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 684 | +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" | |
| 685 | +dependencies = [ | |
| 686 | + "nom 7.1.3", | |
| 687 | +] | |
| 688 | + | |
| 689 | +[[package]] | |
| 690 | +name = "cfg-if" | |
| 691 | +version = "1.0.5" | |
| 692 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 693 | +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" | |
| 694 | + | |
| 695 | +[[package]] | |
| 696 | +name = "cfg_aliases" | |
| 697 | +version = "0.2.2" | |
| 698 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 699 | +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" | |
| 700 | + | |
| 701 | +[[package]] | |
| 702 | +name = "cfg_block" | |
| 703 | +version = "0.1.1" | |
| 704 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 705 | +checksum = "18758054972164c3264f7c8386f5fc6da6114cb46b619fd365d4e3b2dc3ae487" | |
| 706 | + | |
| 707 | +[[package]] | |
| 708 | +name = "chacha20" | |
| 709 | +version = "0.10.2" | |
| 710 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 711 | +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" | |
| 712 | +dependencies = [ | |
| 713 | + "cfg-if", | |
| 714 | + "cipher 0.5.2", | |
| 715 | + "cpufeatures 0.3.1", | |
| 716 | + "rand_core 0.10.1", | |
| 717 | + "zeroize", | |
| 718 | +] | |
| 719 | + | |
| 720 | +[[package]] | |
| 721 | +name = "chacha20poly1305" | |
| 722 | +version = "0.11.0" | |
| 723 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 724 | +checksum = "9b89e1c441e926b9c82a8d023f6e1b7ae0adcfaa7d621814e4d60789bac751cb" | |
| 725 | +dependencies = [ | |
| 726 | + "aead 0.6.1", | |
| 727 | + "chacha20", | |
| 728 | + "cipher 0.5.2", | |
| 729 | + "poly1305", | |
| 730 | +] | |
| 731 | + | |
| 732 | +[[package]] | |
| 733 | +name = "chrono" | |
| 734 | +version = "0.4.45" | |
| 735 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 736 | +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" | |
| 737 | +dependencies = [ | |
| 738 | + "iana-time-zone", | |
| 739 | + "js-sys", | |
| 740 | + "num-traits", | |
| 741 | + "serde", | |
| 742 | + "wasm-bindgen", | |
| 743 | + "windows-link", | |
| 744 | +] | |
| 745 | + | |
| 746 | +[[package]] | |
| 747 | +name = "cipher" | |
| 748 | +version = "0.4.4" | |
| 749 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 750 | +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" | |
| 751 | +dependencies = [ | |
| 752 | + "crypto-common 0.1.7", | |
| 753 | + "inout 0.1.4", | |
| 754 | +] | |
| 755 | + | |
| 756 | +[[package]] | |
| 757 | +name = "cipher" | |
| 758 | +version = "0.5.2" | |
| 759 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 760 | +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" | |
| 761 | +dependencies = [ | |
| 762 | + "block-buffer 0.12.1", | |
| 763 | + "crypto-common 0.2.2", | |
| 764 | + "inout 0.2.3", | |
| 765 | + "zeroize", | |
| 766 | +] | |
| 767 | + | |
| 768 | +[[package]] | |
| 769 | +name = "clang-sys" | |
| 770 | +version = "1.9.1" | |
| 771 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 772 | +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" | |
| 773 | +dependencies = [ | |
| 774 | + "glob", | |
| 775 | + "libc", | |
| 776 | + "libloading", | |
| 777 | +] | |
| 778 | + | |
| 779 | +[[package]] | |
| 780 | +name = "clap" | |
| 781 | +version = "4.6.7" | |
| 782 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 783 | +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" | |
| 784 | +dependencies = [ | |
| 785 | + "clap_builder", | |
| 786 | + "clap_derive", | |
| 787 | +] | |
| 788 | + | |
| 789 | +[[package]] | |
| 790 | +name = "clap_builder" | |
| 791 | +version = "4.6.7" | |
| 792 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 793 | +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" | |
| 794 | +dependencies = [ | |
| 795 | + "anstream", | |
| 796 | + "anstyle", | |
| 797 | + "clap_lex", | |
| 798 | + "strsim", | |
| 799 | + "terminal_size", | |
| 800 | +] | |
| 801 | + | |
| 802 | +[[package]] | |
| 803 | +name = "clap_derive" | |
| 804 | +version = "4.6.7" | |
| 805 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 806 | +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" | |
| 807 | +dependencies = [ | |
| 808 | + "heck", | |
| 809 | + "proc-macro2", | |
| 810 | + "quote", | |
| 811 | + "syn 3.0.9", | |
| 812 | +] | |
| 813 | + | |
| 814 | +[[package]] | |
| 815 | +name = "clap_lex" | |
| 816 | +version = "1.1.1" | |
| 817 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 818 | +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" | |
| 819 | + | |
| 820 | +[[package]] | |
| 821 | +name = "clru" | |
| 822 | +version = "0.6.3" | |
| 823 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 824 | +checksum = "197fd99cb113a8d5d9b6376f3aa817f32c1078f2343b714fff7d2ca44fdf67d5" | |
| 825 | +dependencies = [ | |
| 826 | + "hashbrown 0.16.1", | |
| 827 | +] | |
| 828 | + | |
| 829 | +[[package]] | |
| 830 | +name = "cmake" | |
| 831 | +version = "0.1.58" | |
| 832 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 833 | +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" | |
| 834 | +dependencies = [ | |
| 835 | + "cc", | |
| 836 | +] | |
| 837 | + | |
| 838 | +[[package]] | |
| 839 | +name = "cmov" | |
| 840 | +version = "0.5.4" | |
| 841 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 842 | +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" | |
| 843 | + | |
| 844 | +[[package]] | |
| 845 | +name = "codemap" | |
| 846 | +version = "0.1.3" | |
| 847 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 848 | +checksum = "b9e769b5c8c8283982a987c6e948e540254f1058d5a74b8794914d4ef5fc2a24" | |
| 849 | + | |
| 850 | +[[package]] | |
| 851 | +name = "color_quant" | |
| 852 | +version = "1.1.0" | |
| 853 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 854 | +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" | |
| 855 | + | |
| 856 | +[[package]] | |
| 857 | +name = "colorchoice" | |
| 858 | +version = "1.0.5" | |
| 859 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 860 | +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" | |
| 861 | + | |
| 862 | +[[package]] | |
| 863 | +name = "combine" | |
| 864 | +version = "4.6.8" | |
| 865 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 866 | +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" | |
| 867 | +dependencies = [ | |
| 868 | + "bytes", | |
| 869 | + "memchr", | |
| 870 | +] | |
| 871 | + | |
| 872 | +[[package]] | |
| 873 | +name = "compression-codecs" | |
| 874 | +version = "0.4.45" | |
| 875 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 876 | +checksum = "98fc98460ba0ad5317075d3632b8dfc45d0be8c4a49347c2a38272019717614a" | |
| 877 | +dependencies = [ | |
| 878 | + "brotli", | |
| 879 | + "compression-core", | |
| 880 | + "flate2", | |
| 881 | + "memchr", | |
| 882 | +] | |
| 883 | + | |
| 884 | +[[package]] | |
| 885 | +name = "compression-core" | |
| 886 | +version = "0.4.33" | |
| 887 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 888 | +checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" | |
| 889 | + | |
| 890 | +[[package]] | |
| 891 | +name = "comrak" | |
| 892 | +version = "0.56.0" | |
| 893 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 894 | +checksum = "2544c4d6b6da7d54e95ac2833b464bf3dffa0666237868f2bf4ec5e11c9ba8cc" | |
| 895 | +dependencies = [ | |
| 896 | + "bon", | |
| 897 | + "caseless", | |
| 898 | + "clap", | |
| 899 | + "emojis", | |
| 900 | + "entities", | |
| 901 | + "finl_unicode", | |
| 902 | + "fmt2io", | |
| 903 | + "memchr-n", | |
| 904 | + "phf 0.14.0", | |
| 905 | + "phf_codegen 0.14.0", | |
| 906 | + "rustc-hash 2.1.3", | |
| 907 | + "shell-words", | |
| 908 | + "smallvec", | |
| 909 | + "syntect", | |
| 910 | + "typed-arena", | |
| 911 | + "xdg", | |
| 912 | +] | |
| 913 | + | |
| 914 | +[[package]] | |
| 915 | +name = "concurrent-queue" | |
| 916 | +version = "2.5.0" | |
| 917 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 918 | +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" | |
| 919 | +dependencies = [ | |
| 920 | + "crossbeam-utils", | |
| 921 | +] | |
| 922 | + | |
| 923 | +[[package]] | |
| 924 | +name = "const-oid" | |
| 925 | +version = "0.10.2" | |
| 926 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 927 | +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" | |
| 928 | + | |
| 929 | +[[package]] | |
| 930 | +name = "const_format" | |
| 931 | +version = "0.2.36" | |
| 932 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 933 | +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" | |
| 934 | +dependencies = [ | |
| 935 | + "const_format_proc_macros", | |
| 936 | + "konst", | |
| 937 | +] | |
| 938 | + | |
| 939 | +[[package]] | |
| 940 | +name = "const_format_proc_macros" | |
| 941 | +version = "0.2.34" | |
| 942 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 943 | +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" | |
| 944 | +dependencies = [ | |
| 945 | + "proc-macro2", | |
| 946 | + "quote", | |
| 947 | + "unicode-xid", | |
| 948 | +] | |
| 949 | + | |
| 950 | +[[package]] | |
| 951 | +name = "cookie" | |
| 952 | +version = "0.18.2" | |
| 953 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 954 | +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" | |
| 955 | +dependencies = [ | |
| 956 | + "aes-gcm 0.10.3", | |
| 957 | + "base64 0.22.1", | |
| 958 | + "hkdf 0.12.4", | |
| 959 | + "hmac 0.12.1", | |
| 960 | + "percent-encoding", | |
| 961 | + "rand 0.8.8", | |
| 962 | + "sha2 0.10.9", | |
| 963 | + "subtle", | |
| 964 | + "time", | |
| 965 | + "version_check", | |
| 966 | +] | |
| 967 | + | |
| 968 | +[[package]] | |
| 969 | +name = "core-foundation" | |
| 970 | +version = "0.10.1" | |
| 971 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 972 | +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" | |
| 973 | +dependencies = [ | |
| 974 | + "core-foundation-sys", | |
| 975 | + "libc", | |
| 976 | +] | |
| 977 | + | |
| 978 | +[[package]] | |
| 979 | +name = "core-foundation-sys" | |
| 980 | +version = "0.8.7" | |
| 981 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 982 | +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" | |
| 983 | + | |
| 984 | +[[package]] | |
| 985 | +name = "core_detect" | |
| 986 | +version = "1.0.0" | |
| 987 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 988 | +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" | |
| 989 | + | |
| 990 | +[[package]] | |
| 991 | +name = "cpubits" | |
| 992 | +version = "0.1.1" | |
| 993 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 994 | +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" | |
| 995 | + | |
| 996 | +[[package]] | |
| 997 | +name = "cpufeatures" | |
| 998 | +version = "0.2.17" | |
| 999 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1000 | +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" | |
| 1001 | +dependencies = [ | |
| 1002 | + "libc", | |
| 1003 | +] | |
| 1004 | + | |
| 1005 | +[[package]] | |
| 1006 | +name = "cpufeatures" | |
| 1007 | +version = "0.3.1" | |
| 1008 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1009 | +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" | |
| 1010 | +dependencies = [ | |
| 1011 | + "libc", | |
| 1012 | +] | |
| 1013 | + | |
| 1014 | +[[package]] | |
| 1015 | +name = "crc32c" | |
| 1016 | +version = "0.6.8" | |
| 1017 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1018 | +checksum = "3a47af21622d091a8f0fb295b88bc886ac74efcc613efc19f5d0b21de5c89e47" | |
| 1019 | +dependencies = [ | |
| 1020 | + "rustc_version", | |
| 1021 | +] | |
| 1022 | + | |
| 1023 | +[[package]] | |
| 1024 | +name = "crc32fast" | |
| 1025 | +version = "1.5.2" | |
| 1026 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1027 | +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" | |
| 1028 | +dependencies = [ | |
| 1029 | + "cfg-if", | |
| 1030 | +] | |
| 1031 | + | |
| 1032 | +[[package]] | |
| 1033 | +name = "crossbeam-channel" | |
| 1034 | +version = "0.5.17" | |
| 1035 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1036 | +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" | |
| 1037 | +dependencies = [ | |
| 1038 | + "crossbeam-utils", | |
| 1039 | +] | |
| 1040 | + | |
| 1041 | +[[package]] | |
| 1042 | +name = "crossbeam-deque" | |
| 1043 | +version = "0.8.8" | |
| 1044 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1045 | +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" | |
| 1046 | +dependencies = [ | |
| 1047 | + "crossbeam-epoch", | |
| 1048 | + "crossbeam-utils", | |
| 1049 | +] | |
| 1050 | + | |
| 1051 | +[[package]] | |
| 1052 | +name = "crossbeam-epoch" | |
| 1053 | +version = "0.9.21" | |
| 1054 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1055 | +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" | |
| 1056 | +dependencies = [ | |
| 1057 | + "crossbeam-utils", | |
| 1058 | +] | |
| 1059 | + | |
| 1060 | +[[package]] | |
| 1061 | +name = "crossbeam-utils" | |
| 1062 | +version = "0.8.23" | |
| 1063 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1064 | +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" | |
| 1065 | + | |
| 1066 | +[[package]] | |
| 1067 | +name = "crunchy" | |
| 1068 | +version = "0.2.4" | |
| 1069 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1070 | +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" | |
| 1071 | + | |
| 1072 | +[[package]] | |
| 1073 | +name = "crypto-bigint" | |
| 1074 | +version = "0.7.5" | |
| 1075 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1076 | +checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" | |
| 1077 | +dependencies = [ | |
| 1078 | + "cpubits", | |
| 1079 | + "ctutils", | |
| 1080 | + "getrandom 0.4.3", | |
| 1081 | + "hybrid-array", | |
| 1082 | + "num-traits", | |
| 1083 | + "rand_core 0.10.1", | |
| 1084 | + "serdect", | |
| 1085 | + "subtle", | |
| 1086 | + "zeroize", | |
| 1087 | +] | |
| 1088 | + | |
| 1089 | +[[package]] | |
| 1090 | +name = "crypto-common" | |
| 1091 | +version = "0.1.7" | |
| 1092 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1093 | +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" | |
| 1094 | +dependencies = [ | |
| 1095 | + "generic-array 0.14.7", | |
| 1096 | + "rand_core 0.6.4", | |
| 1097 | + "typenum", | |
| 1098 | +] | |
| 1099 | + | |
| 1100 | +[[package]] | |
| 1101 | +name = "crypto-common" | |
| 1102 | +version = "0.2.2" | |
| 1103 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1104 | +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" | |
| 1105 | +dependencies = [ | |
| 1106 | + "getrandom 0.4.3", | |
| 1107 | + "hybrid-array", | |
| 1108 | + "rand_core 0.10.1", | |
| 1109 | +] | |
| 1110 | + | |
| 1111 | +[[package]] | |
| 1112 | +name = "crypto-primes" | |
| 1113 | +version = "0.7.2" | |
| 1114 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1115 | +checksum = "3633a51a39c69ebbaa4feaa694bd83d241e4093901c84a0963b19d9bb3f0cf8f" | |
| 1116 | +dependencies = [ | |
| 1117 | + "crypto-bigint", | |
| 1118 | + "rand_core 0.10.1", | |
| 1119 | +] | |
| 1120 | + | |
| 1121 | +[[package]] | |
| 1122 | +name = "cssparser" | |
| 1123 | +version = "0.37.0" | |
| 1124 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1125 | +checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" | |
| 1126 | +dependencies = [ | |
| 1127 | + "cssparser-macros", | |
| 1128 | + "dtoa-short", | |
| 1129 | + "itoa", | |
| 1130 | + "phf 0.13.1", | |
| 1131 | + "smallvec", | |
| 1132 | +] | |
| 1133 | + | |
| 1134 | +[[package]] | |
| 1135 | +name = "cssparser" | |
| 1136 | +version = "0.38.0" | |
| 1137 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1138 | +checksum = "11119743ad110e8c1bdccd930d7f5c30c99e5fc76a7b63ec9807e84eef0c5f59" | |
| 1139 | +dependencies = [ | |
| 1140 | + "dtoa-short", | |
| 1141 | + "itoa", | |
| 1142 | + "smallvec", | |
| 1143 | +] | |
| 1144 | + | |
| 1145 | +[[package]] | |
| 1146 | +name = "cssparser-macros" | |
| 1147 | +version = "0.7.1" | |
| 1148 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1149 | +checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a" | |
| 1150 | +dependencies = [ | |
| 1151 | + "quote", | |
| 1152 | + "syn 3.0.9", | |
| 1153 | +] | |
| 1154 | + | |
| 1155 | +[[package]] | |
| 1156 | +name = "ctr" | |
| 1157 | +version = "0.9.2" | |
| 1158 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1159 | +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" | |
| 1160 | +dependencies = [ | |
| 1161 | + "cipher 0.4.4", | |
| 1162 | +] | |
| 1163 | + | |
| 1164 | +[[package]] | |
| 1165 | +name = "ctr" | |
| 1166 | +version = "0.10.1" | |
| 1167 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1168 | +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" | |
| 1169 | +dependencies = [ | |
| 1170 | + "cipher 0.5.2", | |
| 1171 | +] | |
| 1172 | + | |
| 1173 | +[[package]] | |
| 1174 | +name = "ctutils" | |
| 1175 | +version = "0.4.3" | |
| 1176 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1177 | +checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" | |
| 1178 | +dependencies = [ | |
| 1179 | + "cmov", | |
| 1180 | + "subtle", | |
| 1181 | +] | |
| 1182 | + | |
| 1183 | +[[package]] | |
| 1184 | +name = "curve25519-dalek" | |
| 1185 | +version = "5.0.0" | |
| 1186 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1187 | +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" | |
| 1188 | +dependencies = [ | |
| 1189 | + "cfg-if", | |
| 1190 | + "cpufeatures 0.3.1", | |
| 1191 | + "curve25519-dalek-derive", | |
| 1192 | + "digest 0.11.3", | |
| 1193 | + "fiat-crypto", | |
| 1194 | + "rand_core 0.10.1", | |
| 1195 | + "rustc_version", | |
| 1196 | + "subtle", | |
| 1197 | + "zeroize", | |
| 1198 | +] | |
| 1199 | + | |
| 1200 | +[[package]] | |
| 1201 | +name = "curve25519-dalek-derive" | |
| 1202 | +version = "0.1.1" | |
| 1203 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1204 | +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" | |
| 1205 | +dependencies = [ | |
| 1206 | + "proc-macro2", | |
| 1207 | + "quote", | |
| 1208 | + "syn 2.0.119", | |
| 1209 | +] | |
| 1210 | + | |
| 1211 | +[[package]] | |
| 1212 | +name = "darling" | |
| 1213 | +version = "0.20.11" | |
| 1214 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1215 | +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" | |
| 1216 | +dependencies = [ | |
| 1217 | + "darling_core 0.20.11", | |
| 1218 | + "darling_macro 0.20.11", | |
| 1219 | +] | |
| 1220 | + | |
| 1221 | +[[package]] | |
| 1222 | +name = "darling" | |
| 1223 | +version = "0.24.1" | |
| 1224 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1225 | +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" | |
| 1226 | +dependencies = [ | |
| 1227 | + "darling_core 0.24.1", | |
| 1228 | + "darling_macro 0.24.1", | |
| 1229 | +] | |
| 1230 | + | |
| 1231 | +[[package]] | |
| 1232 | +name = "darling_core" | |
| 1233 | +version = "0.20.11" | |
| 1234 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1235 | +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" | |
| 1236 | +dependencies = [ | |
| 1237 | + "fnv", | |
| 1238 | + "ident_case", | |
| 1239 | + "proc-macro2", | |
| 1240 | + "quote", | |
| 1241 | + "strsim", | |
| 1242 | + "syn 2.0.119", | |
| 1243 | +] | |
| 1244 | + | |
| 1245 | +[[package]] | |
| 1246 | +name = "darling_core" | |
| 1247 | +version = "0.24.1" | |
| 1248 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1249 | +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" | |
| 1250 | +dependencies = [ | |
| 1251 | + "ident_case", | |
| 1252 | + "proc-macro2", | |
| 1253 | + "quote", | |
| 1254 | + "strsim", | |
| 1255 | + "syn 3.0.9", | |
| 1256 | +] | |
| 1257 | + | |
| 1258 | +[[package]] | |
| 1259 | +name = "darling_macro" | |
| 1260 | +version = "0.20.11" | |
| 1261 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1262 | +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" | |
| 1263 | +dependencies = [ | |
| 1264 | + "darling_core 0.20.11", | |
| 1265 | + "quote", | |
| 1266 | + "syn 2.0.119", | |
| 1267 | +] | |
| 1268 | + | |
| 1269 | +[[package]] | |
| 1270 | +name = "darling_macro" | |
| 1271 | +version = "0.24.1" | |
| 1272 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1273 | +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" | |
| 1274 | +dependencies = [ | |
| 1275 | + "darling_core 0.24.1", | |
| 1276 | + "quote", | |
| 1277 | + "syn 3.0.9", | |
| 1278 | +] | |
| 1279 | + | |
| 1280 | +[[package]] | |
| 1281 | +name = "dashmap" | |
| 1282 | +version = "6.2.1" | |
| 1283 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1284 | +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" | |
| 1285 | +dependencies = [ | |
| 1286 | + "cfg-if", | |
| 1287 | + "crossbeam-utils", | |
| 1288 | + "hashbrown 0.14.5", | |
| 1289 | + "lock_api", | |
| 1290 | + "once_cell", | |
| 1291 | + "parking_lot_core", | |
| 1292 | +] | |
| 1293 | + | |
| 1294 | +[[package]] | |
| 1295 | +name = "data-encoding" | |
| 1296 | +version = "2.11.1" | |
| 1297 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1298 | +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" | |
| 1299 | + | |
| 1300 | +[[package]] | |
| 1301 | +name = "datasketches" | |
| 1302 | +version = "0.2.0" | |
| 1303 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1304 | +checksum = "c286de4e81ea2590afc24d754e0f83810c566f50a1388fa75ebd57928c0d9745" | |
| 1305 | + | |
| 1306 | +[[package]] | |
| 1307 | +name = "defmt" | |
| 1308 | +version = "0.3.100" | |
| 1309 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1310 | +checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad" | |
| 1311 | +dependencies = [ | |
| 1312 | + "defmt 1.1.1", | |
| 1313 | +] | |
| 1314 | + | |
| 1315 | +[[package]] | |
| 1316 | +name = "defmt" | |
| 1317 | +version = "1.1.1" | |
| 1318 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1319 | +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" | |
| 1320 | +dependencies = [ | |
| 1321 | + "bitflags 1.3.2", | |
| 1322 | + "defmt-macros", | |
| 1323 | +] | |
| 1324 | + | |
| 1325 | +[[package]] | |
| 1326 | +name = "defmt-macros" | |
| 1327 | +version = "1.1.1" | |
| 1328 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1329 | +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" | |
| 1330 | +dependencies = [ | |
| 1331 | + "defmt-parser", | |
| 1332 | + "proc-macro2", | |
| 1333 | + "quote", | |
| 1334 | + "syn 2.0.119", | |
| 1335 | +] | |
| 1336 | + | |
| 1337 | +[[package]] | |
| 1338 | +name = "defmt-parser" | |
| 1339 | +version = "1.0.0" | |
| 1340 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1341 | +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" | |
| 1342 | +dependencies = [ | |
| 1343 | + "thiserror 2.0.21", | |
| 1344 | +] | |
| 1345 | + | |
| 1346 | +[[package]] | |
| 1347 | +name = "delegate" | |
| 1348 | +version = "0.13.5" | |
| 1349 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1350 | +checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370" | |
| 1351 | +dependencies = [ | |
| 1352 | + "proc-macro2", | |
| 1353 | + "quote", | |
| 1354 | + "syn 2.0.119", | |
| 1355 | +] | |
| 1356 | + | |
| 1357 | +[[package]] | |
| 1358 | +name = "der" | |
| 1359 | +version = "0.8.2" | |
| 1360 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1361 | +checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" | |
| 1362 | +dependencies = [ | |
| 1363 | + "const-oid", | |
| 1364 | + "pem-rfc7468", | |
| 1365 | + "zeroize", | |
| 1366 | +] | |
| 1367 | + | |
| 1368 | +[[package]] | |
| 1369 | +name = "deranged" | |
| 1370 | +version = "0.5.9" | |
| 1371 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1372 | +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" | |
| 1373 | +dependencies = [ | |
| 1374 | + "serde_core", | |
| 1375 | +] | |
| 1376 | + | |
| 1377 | +[[package]] | |
| 1378 | +name = "derive_builder" | |
| 1379 | +version = "0.20.2" | |
| 1380 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1381 | +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" | |
| 1382 | +dependencies = [ | |
| 1383 | + "derive_builder_macro", | |
| 1384 | +] | |
| 1385 | + | |
| 1386 | +[[package]] | |
| 1387 | +name = "derive_builder_core" | |
| 1388 | +version = "0.20.2" | |
| 1389 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1390 | +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" | |
| 1391 | +dependencies = [ | |
| 1392 | + "darling 0.20.11", | |
| 1393 | + "proc-macro2", | |
| 1394 | + "quote", | |
| 1395 | + "syn 2.0.119", | |
| 1396 | +] | |
| 1397 | + | |
| 1398 | +[[package]] | |
| 1399 | +name = "derive_builder_macro" | |
| 1400 | +version = "0.20.2" | |
| 1401 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1402 | +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" | |
| 1403 | +dependencies = [ | |
| 1404 | + "derive_builder_core", | |
| 1405 | + "syn 2.0.119", | |
| 1406 | +] | |
| 1407 | + | |
| 1408 | +[[package]] | |
| 1409 | +name = "derive_more" | |
| 1410 | +version = "2.1.1" | |
| 1411 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1412 | +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" | |
| 1413 | +dependencies = [ | |
| 1414 | + "derive_more-impl", | |
| 1415 | +] | |
| 1416 | + | |
| 1417 | +[[package]] | |
| 1418 | +name = "derive_more-impl" | |
| 1419 | +version = "2.1.1" | |
| 1420 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1421 | +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" | |
| 1422 | +dependencies = [ | |
| 1423 | + "proc-macro2", | |
| 1424 | + "quote", | |
| 1425 | + "rustc_version", | |
| 1426 | + "syn 2.0.119", | |
| 1427 | +] | |
| 1428 | + | |
| 1429 | +[[package]] | |
| 1430 | +name = "des" | |
| 1431 | +version = "0.9.0" | |
| 1432 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1433 | +checksum = "916a94e407b54f9034d71dd748234cd1e516ced6284009906ae246f177eafe5a" | |
| 1434 | +dependencies = [ | |
| 1435 | + "cipher 0.5.2", | |
| 1436 | +] | |
| 1437 | + | |
| 1438 | +[[package]] | |
| 1439 | +name = "digest" | |
| 1440 | +version = "0.10.7" | |
| 1441 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1442 | +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" | |
| 1443 | +dependencies = [ | |
| 1444 | + "block-buffer 0.10.4", | |
| 1445 | + "crypto-common 0.1.7", | |
| 1446 | + "subtle", | |
| 1447 | +] | |
| 1448 | + | |
| 1449 | +[[package]] | |
| 1450 | +name = "digest" | |
| 1451 | +version = "0.11.3" | |
| 1452 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1453 | +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" | |
| 1454 | +dependencies = [ | |
| 1455 | + "block-buffer 0.12.1", | |
| 1456 | + "const-oid", | |
| 1457 | + "crypto-common 0.2.2", | |
| 1458 | + "ctutils", | |
| 1459 | +] | |
| 1460 | + | |
| 1461 | +[[package]] | |
| 1462 | +name = "displaydoc" | |
| 1463 | +version = "0.2.7" | |
| 1464 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1465 | +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" | |
| 1466 | +dependencies = [ | |
| 1467 | + "proc-macro2", | |
| 1468 | + "quote", | |
| 1469 | + "syn 3.0.9", | |
| 1470 | +] | |
| 1471 | + | |
| 1472 | +[[package]] | |
| 1473 | +name = "downcast-rs" | |
| 1474 | +version = "2.0.2" | |
| 1475 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1476 | +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" | |
| 1477 | + | |
| 1478 | +[[package]] | |
| 1479 | +name = "dtoa" | |
| 1480 | +version = "1.0.11" | |
| 1481 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1482 | +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" | |
| 1483 | + | |
| 1484 | +[[package]] | |
| 1485 | +name = "dtoa-short" | |
| 1486 | +version = "0.3.5" | |
| 1487 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1488 | +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" | |
| 1489 | +dependencies = [ | |
| 1490 | + "dtoa", | |
| 1491 | +] | |
| 1492 | + | |
| 1493 | +[[package]] | |
| 1494 | +name = "dunce" | |
| 1495 | +version = "1.0.5" | |
| 1496 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1497 | +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" | |
| 1498 | + | |
| 1499 | +[[package]] | |
| 1500 | +name = "ecdsa" | |
| 1501 | +version = "0.17.0" | |
| 1502 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1503 | +checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" | |
| 1504 | +dependencies = [ | |
| 1505 | + "der", | |
| 1506 | + "digest 0.11.3", | |
| 1507 | + "elliptic-curve", | |
| 1508 | + "rfc6979", | |
| 1509 | + "signature", | |
| 1510 | + "spki", | |
| 1511 | + "zeroize", | |
| 1512 | +] | |
| 1513 | + | |
| 1514 | +[[package]] | |
| 1515 | +name = "ed25519" | |
| 1516 | +version = "3.0.0" | |
| 1517 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1518 | +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" | |
| 1519 | +dependencies = [ | |
| 1520 | + "pkcs8", | |
| 1521 | + "signature", | |
| 1522 | +] | |
| 1523 | + | |
| 1524 | +[[package]] | |
| 1525 | +name = "ed25519-dalek" | |
| 1526 | +version = "3.0.0" | |
| 1527 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1528 | +checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" | |
| 1529 | +dependencies = [ | |
| 1530 | + "curve25519-dalek", | |
| 1531 | + "ed25519", | |
| 1532 | + "rand_core 0.10.1", | |
| 1533 | + "serde", | |
| 1534 | + "sha2 0.11.1", | |
| 1535 | + "signature", | |
| 1536 | + "subtle", | |
| 1537 | + "zeroize", | |
| 1538 | +] | |
| 1539 | + | |
| 1540 | +[[package]] | |
| 1541 | +name = "ego-tree" | |
| 1542 | +version = "0.11.0" | |
| 1543 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1544 | +checksum = "b04dc5a38e4f151a79d9f2451ae6037fb6eaf5cba34771f44781f80e508498e3" | |
| 1545 | + | |
| 1546 | +[[package]] | |
| 1547 | +name = "either" | |
| 1548 | +version = "1.19.0" | |
| 1549 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1550 | +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" | |
| 1551 | + | |
| 1552 | +[[package]] | |
| 1553 | +name = "elliptic-curve" | |
| 1554 | +version = "0.14.1" | |
| 1555 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1556 | +checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" | |
| 1557 | +dependencies = [ | |
| 1558 | + "base16ct", | |
| 1559 | + "crypto-bigint", | |
| 1560 | + "crypto-common 0.2.2", | |
| 1561 | + "digest 0.11.3", | |
| 1562 | + "ff", | |
| 1563 | + "group", | |
| 1564 | + "hkdf 0.13.0", | |
| 1565 | + "hybrid-array", | |
| 1566 | + "pem-rfc7468", | |
| 1567 | + "pkcs8", | |
| 1568 | + "rand_core 0.10.1", | |
| 1569 | + "sec1", | |
| 1570 | + "subtle", | |
| 1571 | + "zeroize", | |
| 1572 | +] | |
| 1573 | + | |
| 1574 | +[[package]] | |
| 1575 | +name = "elsa" | |
| 1576 | +version = "1.11.2" | |
| 1577 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1578 | +checksum = "9abf33c656a7256451ebb7d0082c5a471820c31269e49d807c538c252352186e" | |
| 1579 | +dependencies = [ | |
| 1580 | + "stable_deref_trait", | |
| 1581 | +] | |
| 1582 | + | |
| 1583 | +[[package]] | |
| 1584 | +name = "email-encoding" | |
| 1585 | +version = "0.4.2" | |
| 1586 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1587 | +checksum = "420b9da095f052ea597503e39073b5b3c522f7db933fbac202d91d24492693fd" | |
| 1588 | +dependencies = [ | |
| 1589 | + "base64 0.23.1", | |
| 1590 | + "memchr", | |
| 1591 | +] | |
| 1592 | + | |
| 1593 | +[[package]] | |
| 1594 | +name = "email_address" | |
| 1595 | +version = "0.2.9" | |
| 1596 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1597 | +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" | |
| 1598 | + | |
| 1599 | +[[package]] | |
| 1600 | +name = "emojis" | |
| 1601 | +version = "0.9.0" | |
| 1602 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1603 | +checksum = "0b1514ced566c94991ed9563258ecf61e311fd773bf0a3f20606830386bf66e3" | |
| 1604 | +dependencies = [ | |
| 1605 | + "phf 0.13.1", | |
| 1606 | +] | |
| 1607 | + | |
| 1608 | +[[package]] | |
| 1609 | +name = "encoding_rs" | |
| 1610 | +version = "0.8.42" | |
| 1611 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1612 | +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" | |
| 1613 | +dependencies = [ | |
| 1614 | + "cfg-if", | |
| 1615 | + "core_detect", | |
| 1616 | + "multiversion_no_op", | |
| 1617 | + "rustversion", | |
| 1618 | + "scopeguard", | |
| 1619 | + "simdutf8", | |
| 1620 | +] | |
| 1621 | + | |
| 1622 | +[[package]] | |
| 1623 | +name = "entities" | |
| 1624 | +version = "1.0.1" | |
| 1625 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1626 | +checksum = "b5320ae4c3782150d900b79807611a59a99fc9a1d61d686faafc24b93fc8d7ca" | |
| 1627 | + | |
| 1628 | +[[package]] | |
| 1629 | +name = "enum_dispatch" | |
| 1630 | +version = "0.3.13" | |
| 1631 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1632 | +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" | |
| 1633 | +dependencies = [ | |
| 1634 | + "once_cell", | |
| 1635 | + "proc-macro2", | |
| 1636 | + "quote", | |
| 1637 | + "syn 2.0.119", | |
| 1638 | +] | |
| 1639 | + | |
| 1640 | +[[package]] | |
| 1641 | +name = "env_filter" | |
| 1642 | +version = "2.0.0" | |
| 1643 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1644 | +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" | |
| 1645 | +dependencies = [ | |
| 1646 | + "log", | |
| 1647 | + "regex", | |
| 1648 | +] | |
| 1649 | + | |
| 1650 | +[[package]] | |
| 1651 | +name = "env_logger" | |
| 1652 | +version = "0.11.11" | |
| 1653 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1654 | +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" | |
| 1655 | +dependencies = [ | |
| 1656 | + "env_filter", | |
| 1657 | + "log", | |
| 1658 | +] | |
| 1659 | + | |
| 1660 | +[[package]] | |
| 1661 | +name = "equivalent" | |
| 1662 | +version = "1.0.3" | |
| 1663 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1664 | +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" | |
| 1665 | + | |
| 1666 | +[[package]] | |
| 1667 | +name = "erased-serde" | |
| 1668 | +version = "0.4.10" | |
| 1669 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1670 | +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" | |
| 1671 | +dependencies = [ | |
| 1672 | + "serde", | |
| 1673 | + "serde_core", | |
| 1674 | + "typeid", | |
| 1675 | +] | |
| 1676 | + | |
| 1677 | +[[package]] | |
| 1678 | +name = "errno" | |
| 1679 | +version = "0.3.14" | |
| 1680 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1681 | +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" | |
| 1682 | +dependencies = [ | |
| 1683 | + "libc", | |
| 1684 | + "windows-sys 0.61.2", | |
| 1685 | +] | |
| 1686 | + | |
| 1687 | +[[package]] | |
| 1688 | +name = "fallible-iterator" | |
| 1689 | +version = "0.3.0" | |
| 1690 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1691 | +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" | |
| 1692 | + | |
| 1693 | +[[package]] | |
| 1694 | +name = "fancy-regex" | |
| 1695 | +version = "0.16.2" | |
| 1696 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1697 | +checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" | |
| 1698 | +dependencies = [ | |
| 1699 | + "bit-set", | |
| 1700 | + "regex-automata", | |
| 1701 | + "regex-syntax", | |
| 1702 | +] | |
| 1703 | + | |
| 1704 | +[[package]] | |
| 1705 | +name = "fastbloom" | |
| 1706 | +version = "0.14.1" | |
| 1707 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1708 | +checksum = "4e7f34442dbe69c60fe8eaf58a8cafff81a1f278816d8ab4db255b3bef4ac3c4" | |
| 1709 | +dependencies = [ | |
| 1710 | + "getrandom 0.3.4", | |
| 1711 | + "libm", | |
| 1712 | + "siphasher", | |
| 1713 | +] | |
| 1714 | + | |
| 1715 | +[[package]] | |
| 1716 | +name = "fastdivide" | |
| 1717 | +version = "0.4.2" | |
| 1718 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1719 | +checksum = "9afc2bd4d5a73106dd53d10d73d3401c2f32730ba2c0b93ddb888a8983680471" | |
| 1720 | + | |
| 1721 | +[[package]] | |
| 1722 | +name = "faster-hex" | |
| 1723 | +version = "0.10.1" | |
| 1724 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1725 | +checksum = "04839bdf9d8c10f66806fad16b852fc72aab80873aebc3cb69d85b4fa41543ed" | |
| 1726 | +dependencies = [ | |
| 1727 | + "autocfg", | |
| 1728 | + "defmt 0.3.100", | |
| 1729 | + "heapless", | |
| 1730 | + "serde", | |
| 1731 | +] | |
| 1732 | + | |
| 1733 | +[[package]] | |
| 1734 | +name = "fastrand" | |
| 1735 | +version = "2.5.0" | |
| 1736 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1737 | +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" | |
| 1738 | + | |
| 1739 | +[[package]] | |
| 1740 | +name = "fdeflate" | |
| 1741 | +version = "0.3.7" | |
| 1742 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1743 | +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" | |
| 1744 | +dependencies = [ | |
| 1745 | + "simd-adler32", | |
| 1746 | +] | |
| 1747 | + | |
| 1748 | +[[package]] | |
| 1749 | +name = "fearless_simd" | |
| 1750 | +version = "1.1.0" | |
| 1751 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1752 | +checksum = "3296eb97d869113eaf148a6bdf01884e90b3828ad7ae2340b0f93362fc158611" | |
| 1753 | + | |
| 1754 | +[[package]] | |
| 1755 | +name = "fearless_simd_macros" | |
| 1756 | +version = "0.1.0" | |
| 1757 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1758 | +checksum = "3e91d94c6885390b81782d7ee60fd6583b33907333a8b340de25ccf468353315" | |
| 1759 | +dependencies = [ | |
| 1760 | + "proc-macro2", | |
| 1761 | + "quote", | |
| 1762 | + "syn 3.0.9", | |
| 1763 | +] | |
| 1764 | + | |
| 1765 | +[[package]] | |
| 1766 | +name = "ff" | |
| 1767 | +version = "0.14.0" | |
| 1768 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1769 | +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" | |
| 1770 | +dependencies = [ | |
| 1771 | + "rand_core 0.10.1", | |
| 1772 | + "subtle", | |
| 1773 | +] | |
| 1774 | + | |
| 1775 | +[[package]] | |
| 1776 | +name = "fiat-crypto" | |
| 1777 | +version = "0.3.0" | |
| 1778 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1779 | +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" | |
| 1780 | + | |
| 1781 | +[[package]] | |
| 1782 | +name = "filetime" | |
| 1783 | +version = "0.2.29" | |
| 1784 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1785 | +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" | |
| 1786 | +dependencies = [ | |
| 1787 | + "cfg-if", | |
| 1788 | + "libc", | |
| 1789 | +] | |
| 1790 | + | |
| 1791 | +[[package]] | |
| 1792 | +name = "find-msvc-tools" | |
| 1793 | +version = "0.1.14" | |
| 1794 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1795 | +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" | |
| 1796 | + | |
| 1797 | +[[package]] | |
| 1798 | +name = "finl_unicode" | |
| 1799 | +version = "1.5.0" | |
| 1800 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1801 | +checksum = "80bb028c8b4148c9ee0cca68fcd9add6044e81d3619f48577ddf13a263d047a2" | |
| 1802 | + | |
| 1803 | +[[package]] | |
| 1804 | +name = "fixedbitset" | |
| 1805 | +version = "0.5.7" | |
| 1806 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1807 | +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" | |
| 1808 | + | |
| 1809 | +[[package]] | |
| 1810 | +name = "flate2" | |
| 1811 | +version = "1.1.10" | |
| 1812 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1813 | +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" | |
| 1814 | +dependencies = [ | |
| 1815 | + "crc32fast", | |
| 1816 | + "miniz_oxide 0.9.1", | |
| 1817 | + "zlib-rs", | |
| 1818 | +] | |
| 1819 | + | |
| 1820 | +[[package]] | |
| 1821 | +name = "fmt2io" | |
| 1822 | +version = "1.0.0" | |
| 1823 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1824 | +checksum = "6b6129284da9f7e5296cc22183a63f24300e945e297705dcc0672f7df01d62c8" | |
| 1825 | + | |
| 1826 | +[[package]] | |
| 1827 | +name = "fnv" | |
| 1828 | +version = "1.0.7" | |
| 1829 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1830 | +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" | |
| 1831 | + | |
| 1832 | +[[package]] | |
| 1833 | +name = "foldhash" | |
| 1834 | +version = "0.2.0" | |
| 1835 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1836 | +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" | |
| 1837 | + | |
| 1838 | +[[package]] | |
| 1839 | +name = "forge-ci" | |
| 1840 | +version = "0.1.0" | |
| 1841 | +dependencies = [ | |
| 1842 | + "base64 0.23.1", | |
| 1843 | + "globset", | |
| 1844 | + "serde", | |
| 1845 | + "serde_json", | |
| 1846 | + "thiserror 2.0.21", | |
| 1847 | + "toml", | |
| 1848 | +] | |
| 1849 | + | |
| 1850 | +[[package]] | |
| 1851 | +name = "forge-core" | |
| 1852 | +version = "0.1.0" | |
| 1853 | +dependencies = [ | |
| 1854 | + "argon2", | |
| 1855 | + "base64 0.23.1", | |
| 1856 | + "chacha20poly1305", | |
| 1857 | + "hex", | |
| 1858 | + "hmac 0.13.0", | |
| 1859 | + "image", | |
| 1860 | + "ipnet", | |
| 1861 | + "jiff", | |
| 1862 | + "rand 0.10.3", | |
| 1863 | + "semver", | |
| 1864 | + "serde", | |
| 1865 | + "serde_json", | |
| 1866 | + "sha1 0.11.0", | |
| 1867 | + "sha2 0.11.1", | |
| 1868 | + "subtle", | |
| 1869 | + "tempfile", | |
| 1870 | + "thiserror 2.0.21", | |
| 1871 | + "time", | |
| 1872 | + "tokio", | |
| 1873 | + "toml", | |
| 1874 | + "tracing", | |
| 1875 | + "turso", | |
| 1876 | + "ulid", | |
| 1877 | + "unicode-normalization", | |
| 1878 | + "zip", | |
| 1879 | +] | |
| 1880 | + | |
| 1881 | +[[package]] | |
| 1882 | +name = "forge-git" | |
| 1883 | +version = "0.1.0" | |
| 1884 | +dependencies = [ | |
| 1885 | + "base64 0.23.1", | |
| 1886 | + "forge-core", | |
| 1887 | + "gix", | |
| 1888 | + "hex", | |
| 1889 | + "sha2 0.11.1", | |
| 1890 | + "tempfile", | |
| 1891 | + "thiserror 2.0.21", | |
| 1892 | + "tokio", | |
| 1893 | +] | |
| 1894 | + | |
| 1895 | +[[package]] | |
| 1896 | +name = "forge-runner" | |
| 1897 | +version = "0.1.0" | |
| 1898 | +dependencies = [ | |
| 1899 | + "base64 0.23.1", | |
| 1900 | + "bytes", | |
| 1901 | + "clap", | |
| 1902 | + "flate2", | |
| 1903 | + "forge-ci", | |
| 1904 | + "forge-runner", | |
| 1905 | + "globset", | |
| 1906 | + "hex", | |
| 1907 | + "http-body-util", | |
| 1908 | + "hyper", | |
| 1909 | + "hyper-util", | |
| 1910 | + "libcontainer", | |
| 1911 | + "nix 0.31.3", | |
| 1912 | + "oci-client", | |
| 1913 | + "oci-spec", | |
| 1914 | + "reqwest", | |
| 1915 | + "seccompiler", | |
| 1916 | + "serde", | |
| 1917 | + "serde_json", | |
| 1918 | + "sha2 0.11.1", | |
| 1919 | + "tar", | |
| 1920 | + "tempfile", | |
| 1921 | + "thiserror 2.0.21", | |
| 1922 | + "tokio", | |
| 1923 | + "tokio-util", | |
| 1924 | + "toml", | |
| 1925 | + "tracing", | |
| 1926 | + "tracing-subscriber", | |
| 1927 | + "zstd 0.14.2", | |
| 1928 | +] | |
| 1929 | + | |
| 1930 | +[[package]] | |
| 1931 | +name = "forge-ssh" | |
| 1932 | +version = "0.1.0" | |
| 1933 | +dependencies = [ | |
| 1934 | + "bytes", | |
| 1935 | + "forge-git", | |
| 1936 | + "getrandom 0.4.3", | |
| 1937 | + "russh", | |
| 1938 | + "tempfile", | |
| 1939 | + "thiserror 2.0.21", | |
| 1940 | + "tokio", | |
| 1941 | + "tracing", | |
| 1942 | +] | |
| 1943 | + | |
| 1944 | +[[package]] | |
| 1945 | +name = "forge-web" | |
| 1946 | +version = "0.1.0" | |
| 1947 | +dependencies = [ | |
| 1948 | + "ammonia", | |
| 1949 | + "async-compression", | |
| 1950 | + "base64 0.23.1", | |
| 1951 | + "bytes", | |
| 1952 | + "comrak", | |
| 1953 | + "emojis", | |
| 1954 | + "forge-ci", | |
| 1955 | + "forge-core", | |
| 1956 | + "forge-git", | |
| 1957 | + "forge-runner", | |
| 1958 | + "forge-ssh", | |
| 1959 | + "form_urlencoded", | |
| 1960 | + "futures-util", | |
| 1961 | + "grass", | |
| 1962 | + "hex", | |
| 1963 | + "hmac 0.13.0", | |
| 1964 | + "http", | |
| 1965 | + "http-body", | |
| 1966 | + "http-body-util", | |
| 1967 | + "image", | |
| 1968 | + "ipnet", | |
| 1969 | + "jiff", | |
| 1970 | + "lettre", | |
| 1971 | + "multer", | |
| 1972 | + "object_store", | |
| 1973 | + "rand 0.10.3", | |
| 1974 | + "reqwest", | |
| 1975 | + "rpassword", | |
| 1976 | + "scraper", | |
| 1977 | + "serde", | |
| 1978 | + "serde_json", | |
| 1979 | + "sha2 0.11.1", | |
| 1980 | + "similar", | |
| 1981 | + "subtle", | |
| 1982 | + "syntect", | |
| 1983 | + "tempfile", | |
| 1984 | + "time", | |
| 1985 | + "tokio", | |
| 1986 | + "tokio-util", | |
| 1987 | + "topcoat", | |
| 1988 | + "tower", | |
| 1989 | + "tower-http 0.7.1", | |
| 1990 | + "tracing", | |
| 1991 | + "tracing-subscriber", | |
| 1992 | + "ulid", | |
| 1993 | + "zip", | |
| 1994 | +] | |
| 1995 | + | |
| 1996 | +[[package]] | |
| 1997 | +name = "form_urlencoded" | |
| 1998 | +version = "1.2.2" | |
| 1999 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2000 | +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" |
Cargo.toml| @@ -0,0 +1,124 @@ | ||
| 1 | +[workspace] | |
| 2 | +resolver = "3" | |
| 3 | +members = [ | |
| 4 | + "crates/forge-core", | |
| 5 | + "crates/forge-git", | |
| 6 | + "crates/forge-ssh", | |
| 7 | + "crates/forge-web", | |
| 8 | + "crates/forge-ci", | |
| 9 | + "crates/forge-runner", | |
| 10 | +] | |
| 11 | + | |
| 12 | +[workspace.package] | |
| 13 | +version = "0.1.0" | |
| 14 | +edition = "2024" | |
| 15 | +license = "AGPL-3.0-or-later" | |
| 16 | + | |
| 17 | +[workspace.dependencies] | |
| 18 | +tower = { version = "0.5.3", features = ["util"] } | |
| 19 | +tower-http = { version = "0.7.1", features = ["compression-br", "compression-gzip"] } | |
| 20 | +gix = { version = "0.89.0", default-features = false, features = ["revision", "blob-diff", "max-performance-safe", "sha1"] } | |
| 21 | +comrak = "0.56.0" | |
| 22 | +emojis = "0.9.0" | |
| 23 | +ammonia = "4.2.3" | |
| 24 | +syntect = { version = "5.3.0", default-features = false, features = ["default-fancy"] } | |
| 25 | +hmac = "0.13.0" | |
| 26 | +sha1 = "0.11.0" | |
| 27 | +image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp", "gif"] } | |
| 28 | +# internal crates | |
| 29 | +forge-core = { path = "crates/forge-core" } | |
| 30 | +forge-ci = { path = "crates/forge-ci" } | |
| 31 | +forge-git = { path = "crates/forge-git" } | |
| 32 | +forge-ssh = { path = "crates/forge-ssh" } | |
| 33 | +forge-runner = { path = "crates/forge-runner" } | |
| 34 | + | |
| 35 | +# async runtime and HTTP plumbing | |
| 36 | +tokio = { version = "1.53.3", features = ["full"] } | |
| 37 | +futures-util = "0.3.34" | |
| 38 | +bytes = "1.12.1" | |
| 39 | +http = "1.5.0" | |
| 40 | +http-body-util = "0.1.5" | |
| 41 | +async-compression = { version = "0.4.50", features = ["tokio", "gzip"] } | |
| 42 | +tokio-util = "0.7.20" | |
| 43 | +http-body = "1.1.0" | |
| 44 | + | |
| 45 | +# errors, serialization, data | |
| 46 | +thiserror = "2.0.21" | |
| 47 | +globset = "0.4.20" | |
| 48 | +ipnet = "2.12.2" | |
| 49 | +serde = { version = "1.0.229", features = ["derive"] } | |
| 50 | +serde_json = "1.0.154" | |
| 51 | +toml = "1.1.8" | |
| 52 | +ulid = "3.0.0" | |
| 53 | +time = { version = "0.3.55", features = ["formatting", "parsing", "macros"] } | |
| 54 | +unicode-normalization = "0.1.25" | |
| 55 | +form_urlencoded = "1.2.2" | |
| 56 | +jiff = { version = "0.2.38", default-features = false, features = ["std", "tzdb-bundle-always"] } | |
| 57 | + | |
| 58 | +# crypto | |
| 59 | +argon2 = "0.6.0" | |
| 60 | +rand = "0.10.3" | |
| 61 | +sha2 = "0.11.1" | |
| 62 | +hex = "0.4.3" | |
| 63 | +subtle = "2.6.1" | |
| 64 | +base64 = "0.23.1" | |
| 65 | +getrandom = "0.4.3" | |
| 66 | +rpassword = "7.5.4" | |
| 67 | +chacha20poly1305 = "0.11.0" | |
| 68 | + | |
| 69 | +# storage | |
| 70 | +turso = "0.8.3" | |
| 71 | +object_store = { version = "0.14.2", default-features = false, features = ["fs"] } | |
| 72 | +multer = "3.1.0" | |
| 73 | + | |
| 74 | +# outgoing mail (forge-web): SMTP over rustls, no native TLS | |
| 75 | +lettre = { version = "0.11.23", default-features = false, features = ["smtp-transport", "builder", "tokio1-rustls", "ring", "webpki-roots", "pool"] } | |
| 76 | + | |
| 77 | +# web framework | |
| 78 | +topcoat = { version = "0.10.0", default-features = false, features = ["router", "view", "runtime", "cookie", "asset", "serve", "sse", "discover", "multipart"] } | |
| 79 | + | |
| 80 | +# ssh server | |
| 81 | +russh = "0.64.1" | |
| 82 | + | |
| 83 | +# observability | |
| 84 | +tracing = "0.1.44" | |
| 85 | +tracing-subscriber = "0.3.23" | |
| 86 | + | |
| 87 | +# OCI image pulls and layer unpacking (forge-runner) | |
| 88 | +oci-client = "0.18.0" | |
| 89 | +tar = "0.4.46" | |
| 90 | +flate2 = "1.1.10" | |
| 91 | +zstd = "0.14.2" | |
| 92 | + | |
| 93 | +# CLI and test servers (forge-runner) | |
| 94 | +clap = { version = "4.6.7", features = ["derive"] } | |
| 95 | +hyper = { version = "1.12.0", features = ["server", "http1"] } | |
| 96 | +hyper-util = { version = "0.1.21", features = ["tokio"] } | |
| 97 | + | |
| 98 | +# sandbox (forge-runner): in-process containers, no systemd, no youki CLI, no C libraries. | |
| 99 | +# The seccomp profile is built with the pure-Rust seccompiler (json feature resolves syscall names) | |
| 100 | +# and installed by forge-runner's own executor, so libcontainer's libseccomp feature stays off. | |
| 101 | +libcontainer = { version = "0.7.0", default-features = false, features = ["v2"] } | |
| 102 | +seccompiler = { version = "0.5.0", features = ["json"] } | |
| 103 | +oci-spec = "0.10.0" | |
| 104 | +nix = { version = "0.31.3", features = ["signal", "process", "mount", "sched", "user", "resource", "fs"] } | |
| 105 | + | |
| 106 | +# build and test | |
| 107 | +grass = "0.13.4" | |
| 108 | +tempfile = "3.27.0" | |
| 109 | +reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "form", "stream"] } | |
| 110 | +scraper = "0.27.0" | |
| 111 | +similar = "3.2.0" | |
| 112 | + | |
| 113 | +# security advisories (forge-core): semver matching and OSV zip dumps | |
| 114 | +semver = "1.0.28" | |
| 115 | +zip = { version = "9.0.3", default-features = false, features = ["deflate"] } | |
| 116 | + | |
| 117 | +[profile.release] | |
| 118 | +lto = true | |
| 119 | +codegen-units = 1 | |
| 120 | + | |
| 121 | +[profile.dist] | |
| 122 | +inherits = "release" | |
| 123 | +lto = "thin" | |
| 124 | +codegen-units = 16 |
LICENSE| @@ -0,0 +1,661 @@ | ||
| 1 | + GNU AFFERO GENERAL PUBLIC LICENSE | |
| 2 | + Version 3, 19 November 2007 | |
| 3 | + | |
| 4 | + Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> | |
| 5 | + Everyone is permitted to copy and distribute verbatim copies | |
| 6 | + of this license document, but changing it is not allowed. | |
| 7 | + | |
| 8 | + Preamble | |
| 9 | + | |
| 10 | + The GNU Affero General Public License is a free, copyleft license for | |
| 11 | +software and other kinds of works, specifically designed to ensure | |
| 12 | +cooperation with the community in the case of network server software. | |
| 13 | + | |
| 14 | + The licenses for most software and other practical works are designed | |
| 15 | +to take away your freedom to share and change the works. By contrast, | |
| 16 | +our General Public Licenses are intended to guarantee your freedom to | |
| 17 | +share and change all versions of a program--to make sure it remains free | |
| 18 | +software for all its users. | |
| 19 | + | |
| 20 | + When we speak of free software, we are referring to freedom, not | |
| 21 | +price. Our General Public Licenses are designed to make sure that you | |
| 22 | +have the freedom to distribute copies of free software (and charge for | |
| 23 | +them if you wish), that you receive source code or can get it if you | |
| 24 | +want it, that you can change the software or use pieces of it in new | |
| 25 | +free programs, and that you know you can do these things. | |
| 26 | + | |
| 27 | + Developers that use our General Public Licenses protect your rights | |
| 28 | +with two steps: (1) assert copyright on the software, and (2) offer | |
| 29 | +you this License which gives you legal permission to copy, distribute | |
| 30 | +and/or modify the software. | |
| 31 | + | |
| 32 | + A secondary benefit of defending all users' freedom is that | |
| 33 | +improvements made in alternate versions of the program, if they | |
| 34 | +receive widespread use, become available for other developers to | |
| 35 | +incorporate. Many developers of free software are heartened and | |
| 36 | +encouraged by the resulting cooperation. However, in the case of | |
| 37 | +software used on network servers, this result may fail to come about. | |
| 38 | +The GNU General Public License permits making a modified version and | |
| 39 | +letting the public access it on a server without ever releasing its | |
| 40 | +source code to the public. | |
| 41 | + | |
| 42 | + The GNU Affero General Public License is designed specifically to | |
| 43 | +ensure that, in such cases, the modified source code becomes available | |
| 44 | +to the community. It requires the operator of a network server to | |
| 45 | +provide the source code of the modified version running there to the | |
| 46 | +users of that server. Therefore, public use of a modified version, on | |
| 47 | +a publicly accessible server, gives the public access to the source | |
| 48 | +code of the modified version. | |
| 49 | + | |
| 50 | + An older license, called the Affero General Public License and | |
| 51 | +published by Affero, was designed to accomplish similar goals. This is | |
| 52 | +a different license, not a version of the Affero GPL, but Affero has | |
| 53 | +released a new version of the Affero GPL which permits relicensing under | |
| 54 | +this license. | |
| 55 | + | |
| 56 | + The precise terms and conditions for copying, distribution and | |
| 57 | +modification follow. | |
| 58 | + | |
| 59 | + TERMS AND CONDITIONS | |
| 60 | + | |
| 61 | + 0. Definitions. | |
| 62 | + | |
| 63 | + "This License" refers to version 3 of the GNU Affero General Public License. | |
| 64 | + | |
| 65 | + "Copyright" also means copyright-like laws that apply to other kinds of | |
| 66 | +works, such as semiconductor masks. | |
| 67 | + | |
| 68 | + "The Program" refers to any copyrightable work licensed under this | |
| 69 | +License. Each licensee is addressed as "you". "Licensees" and | |
| 70 | +"recipients" may be individuals or organizations. | |
| 71 | + | |
| 72 | + To "modify" a work means to copy from or adapt all or part of the work | |
| 73 | +in a fashion requiring copyright permission, other than the making of an | |
| 74 | +exact copy. The resulting work is called a "modified version" of the | |
| 75 | +earlier work or a work "based on" the earlier work. | |
| 76 | + | |
| 77 | + A "covered work" means either the unmodified Program or a work based | |
| 78 | +on the Program. | |
| 79 | + | |
| 80 | + To "propagate" a work means to do anything with it that, without | |
| 81 | +permission, would make you directly or secondarily liable for | |
| 82 | +infringement under applicable copyright law, except executing it on a | |
| 83 | +computer or modifying a private copy. Propagation includes copying, | |
| 84 | +distribution (with or without modification), making available to the | |
| 85 | +public, and in some countries other activities as well. | |
| 86 | + | |
| 87 | + To "convey" a work means any kind of propagation that enables other | |
| 88 | +parties to make or receive copies. Mere interaction with a user through | |
| 89 | +a computer network, with no transfer of a copy, is not conveying. | |
| 90 | + | |
| 91 | + An interactive user interface displays "Appropriate Legal Notices" | |
| 92 | +to the extent that it includes a convenient and prominently visible | |
| 93 | +feature that (1) displays an appropriate copyright notice, and (2) | |
| 94 | +tells the user that there is no warranty for the work (except to the | |
| 95 | +extent that warranties are provided), that licensees may convey the | |
| 96 | +work under this License, and how to view a copy of this License. If | |
| 97 | +the interface presents a list of user commands or options, such as a | |
| 98 | +menu, a prominent item in the list meets this criterion. | |
| 99 | + | |
| 100 | + 1. Source Code. | |
| 101 | + | |
| 102 | + The "source code" for a work means the preferred form of the work | |
| 103 | +for making modifications to it. "Object code" means any non-source | |
| 104 | +form of a work. | |
| 105 | + | |
| 106 | + A "Standard Interface" means an interface that either is an official | |
| 107 | +standard defined by a recognized standards body, or, in the case of | |
| 108 | +interfaces specified for a particular programming language, one that | |
| 109 | +is widely used among developers working in that language. | |
| 110 | + | |
| 111 | + The "System Libraries" of an executable work include anything, other | |
| 112 | +than the work as a whole, that (a) is included in the normal form of | |
| 113 | +packaging a Major Component, but which is not part of that Major | |
| 114 | +Component, and (b) serves only to enable use of the work with that | |
| 115 | +Major Component, or to implement a Standard Interface for which an | |
| 116 | +implementation is available to the public in source code form. A | |
| 117 | +"Major Component", in this context, means a major essential component | |
| 118 | +(kernel, window system, and so on) of the specific operating system | |
| 119 | +(if any) on which the executable work runs, or a compiler used to | |
| 120 | +produce the work, or an object code interpreter used to run it. | |
| 121 | + | |
| 122 | + The "Corresponding Source" for a work in object code form means all | |
| 123 | +the source code needed to generate, install, and (for an executable | |
| 124 | +work) run the object code and to modify the work, including scripts to | |
| 125 | +control those activities. However, it does not include the work's | |
| 126 | +System Libraries, or general-purpose tools or generally available free | |
| 127 | +programs which are used unmodified in performing those activities but | |
| 128 | +which are not part of the work. For example, Corresponding Source | |
| 129 | +includes interface definition files associated with source files for | |
| 130 | +the work, and the source code for shared libraries and dynamically | |
| 131 | +linked subprograms that the work is specifically designed to require, | |
| 132 | +such as by intimate data communication or control flow between those | |
| 133 | +subprograms and other parts of the work. | |
| 134 | + | |
| 135 | + The Corresponding Source need not include anything that users | |
| 136 | +can regenerate automatically from other parts of the Corresponding | |
| 137 | +Source. | |
| 138 | + | |
| 139 | + The Corresponding Source for a work in source code form is that | |
| 140 | +same work. | |
| 141 | + | |
| 142 | + 2. Basic Permissions. | |
| 143 | + | |
| 144 | + All rights granted under this License are granted for the term of | |
| 145 | +copyright on the Program, and are irrevocable provided the stated | |
| 146 | +conditions are met. This License explicitly affirms your unlimited | |
| 147 | +permission to run the unmodified Program. The output from running a | |
| 148 | +covered work is covered by this License only if the output, given its | |
| 149 | +content, constitutes a covered work. This License acknowledges your | |
| 150 | +rights of fair use or other equivalent, as provided by copyright law. | |
| 151 | + | |
| 152 | + You may make, run and propagate covered works that you do not | |
| 153 | +convey, without conditions so long as your license otherwise remains | |
| 154 | +in force. You may convey covered works to others for the sole purpose | |
| 155 | +of having them make modifications exclusively for you, or provide you | |
| 156 | +with facilities for running those works, provided that you comply with | |
| 157 | +the terms of this License in conveying all material for which you do | |
| 158 | +not control copyright. Those thus making or running the covered works | |
| 159 | +for you must do so exclusively on your behalf, under your direction | |
| 160 | +and control, on terms that prohibit them from making any copies of | |
| 161 | +your copyrighted material outside their relationship with you. | |
| 162 | + | |
| 163 | + Conveying under any other circumstances is permitted solely under | |
| 164 | +the conditions stated below. Sublicensing is not allowed; section 10 | |
| 165 | +makes it unnecessary. | |
| 166 | + | |
| 167 | + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. | |
| 168 | + | |
| 169 | + No covered work shall be deemed part of an effective technological | |
| 170 | +measure under any applicable law fulfilling obligations under article | |
| 171 | +11 of the WIPO copyright treaty adopted on 20 December 1996, or | |
| 172 | +similar laws prohibiting or restricting circumvention of such | |
| 173 | +measures. | |
| 174 | + | |
| 175 | + When you convey a covered work, you waive any legal power to forbid | |
| 176 | +circumvention of technological measures to the extent such circumvention | |
| 177 | +is effected by exercising rights under this License with respect to | |
| 178 | +the covered work, and you disclaim any intention to limit operation or | |
| 179 | +modification of the work as a means of enforcing, against the work's | |
| 180 | +users, your or third parties' legal rights to forbid circumvention of | |
| 181 | +technological measures. | |
| 182 | + | |
| 183 | + 4. Conveying Verbatim Copies. | |
| 184 | + | |
| 185 | + You may convey verbatim copies of the Program's source code as you | |
| 186 | +receive it, in any medium, provided that you conspicuously and | |
| 187 | +appropriately publish on each copy an appropriate copyright notice; | |
| 188 | +keep intact all notices stating that this License and any | |
| 189 | +non-permissive terms added in accord with section 7 apply to the code; | |
| 190 | +keep intact all notices of the absence of any warranty; and give all | |
| 191 | +recipients a copy of this License along with the Program. | |
| 192 | + | |
| 193 | + You may charge any price or no price for each copy that you convey, | |
| 194 | +and you may offer support or warranty protection for a fee. | |
| 195 | + | |
| 196 | + 5. Conveying Modified Source Versions. | |
| 197 | + | |
| 198 | + You may convey a work based on the Program, or the modifications to | |
| 199 | +produce it from the Program, in the form of source code under the | |
| 200 | +terms of section 4, provided that you also meet all of these conditions: | |
| 201 | + | |
| 202 | + a) The work must carry prominent notices stating that you modified | |
| 203 | + it, and giving a relevant date. | |
| 204 | + | |
| 205 | + b) The work must carry prominent notices stating that it is | |
| 206 | + released under this License and any conditions added under section | |
| 207 | + 7. This requirement modifies the requirement in section 4 to | |
| 208 | + "keep intact all notices". | |
| 209 | + | |
| 210 | + c) You must license the entire work, as a whole, under this | |
| 211 | + License to anyone who comes into possession of a copy. This | |
| 212 | + License will therefore apply, along with any applicable section 7 | |
| 213 | + additional terms, to the whole of the work, and all its parts, | |
| 214 | + regardless of how they are packaged. This License gives no | |
| 215 | + permission to license the work in any other way, but it does not | |
| 216 | + invalidate such permission if you have separately received it. | |
| 217 | + | |
| 218 | + d) If the work has interactive user interfaces, each must display | |
| 219 | + Appropriate Legal Notices; however, if the Program has interactive | |
| 220 | + interfaces that do not display Appropriate Legal Notices, your | |
| 221 | + work need not make them do so. | |
| 222 | + | |
| 223 | + A compilation of a covered work with other separate and independent | |
| 224 | +works, which are not by their nature extensions of the covered work, | |
| 225 | +and which are not combined with it such as to form a larger program, | |
| 226 | +in or on a volume of a storage or distribution medium, is called an | |
| 227 | +"aggregate" if the compilation and its resulting copyright are not | |
| 228 | +used to limit the access or legal rights of the compilation's users | |
| 229 | +beyond what the individual works permit. Inclusion of a covered work | |
| 230 | +in an aggregate does not cause this License to apply to the other | |
| 231 | +parts of the aggregate. | |
| 232 | + | |
| 233 | + 6. Conveying Non-Source Forms. | |
| 234 | + | |
| 235 | + You may convey a covered work in object code form under the terms | |
| 236 | +of sections 4 and 5, provided that you also convey the | |
| 237 | +machine-readable Corresponding Source under the terms of this License, | |
| 238 | +in one of these ways: | |
| 239 | + | |
| 240 | + a) Convey the object code in, or embodied in, a physical product | |
| 241 | + (including a physical distribution medium), accompanied by the | |
| 242 | + Corresponding Source fixed on a durable physical medium | |
| 243 | + customarily used for software interchange. | |
| 244 | + | |
| 245 | + b) Convey the object code in, or embodied in, a physical product | |
| 246 | + (including a physical distribution medium), accompanied by a | |
| 247 | + written offer, valid for at least three years and valid for as | |
| 248 | + long as you offer spare parts or customer support for that product | |
| 249 | + model, to give anyone who possesses the object code either (1) a | |
| 250 | + copy of the Corresponding Source for all the software in the | |
| 251 | + product that is covered by this License, on a durable physical | |
| 252 | + medium customarily used for software interchange, for a price no | |
| 253 | + more than your reasonable cost of physically performing this | |
| 254 | + conveying of source, or (2) access to copy the | |
| 255 | + Corresponding Source from a network server at no charge. | |
| 256 | + | |
| 257 | + c) Convey individual copies of the object code with a copy of the | |
| 258 | + written offer to provide the Corresponding Source. This | |
| 259 | + alternative is allowed only occasionally and noncommercially, and | |
| 260 | + only if you received the object code with such an offer, in accord | |
| 261 | + with subsection 6b. | |
| 262 | + | |
| 263 | + d) Convey the object code by offering access from a designated | |
| 264 | + place (gratis or for a charge), and offer equivalent access to the | |
| 265 | + Corresponding Source in the same way through the same place at no | |
| 266 | + further charge. You need not require recipients to copy the | |
| 267 | + Corresponding Source along with the object code. If the place to | |
| 268 | + copy the object code is a network server, the Corresponding Source | |
| 269 | + may be on a different server (operated by you or a third party) | |
| 270 | + that supports equivalent copying facilities, provided you maintain | |
| 271 | + clear directions next to the object code saying where to find the | |
| 272 | + Corresponding Source. Regardless of what server hosts the | |
| 273 | + Corresponding Source, you remain obligated to ensure that it is | |
| 274 | + available for as long as needed to satisfy these requirements. | |
| 275 | + | |
| 276 | + e) Convey the object code using peer-to-peer transmission, provided | |
| 277 | + you inform other peers where the object code and Corresponding | |
| 278 | + Source of the work are being offered to the general public at no | |
| 279 | + charge under subsection 6d. | |
| 280 | + | |
| 281 | + A separable portion of the object code, whose source code is excluded | |
| 282 | +from the Corresponding Source as a System Library, need not be | |
| 283 | +included in conveying the object code work. | |
| 284 | + | |
| 285 | + A "User Product" is either (1) a "consumer product", which means any | |
| 286 | +tangible personal property which is normally used for personal, family, | |
| 287 | +or household purposes, or (2) anything designed or sold for incorporation | |
| 288 | +into a dwelling. In determining whether a product is a consumer product, | |
| 289 | +doubtful cases shall be resolved in favor of coverage. For a particular | |
| 290 | +product received by a particular user, "normally used" refers to a | |
| 291 | +typical or common use of that class of product, regardless of the status | |
| 292 | +of the particular user or of the way in which the particular user | |
| 293 | +actually uses, or expects or is expected to use, the product. A product | |
| 294 | +is a consumer product regardless of whether the product has substantial | |
| 295 | +commercial, industrial or non-consumer uses, unless such uses represent | |
| 296 | +the only significant mode of use of the product. | |
| 297 | + | |
| 298 | + "Installation Information" for a User Product means any methods, | |
| 299 | +procedures, authorization keys, or other information required to install | |
| 300 | +and execute modified versions of a covered work in that User Product from | |
| 301 | +a modified version of its Corresponding Source. The information must | |
| 302 | +suffice to ensure that the continued functioning of the modified object | |
| 303 | +code is in no case prevented or interfered with solely because | |
| 304 | +modification has been made. | |
| 305 | + | |
| 306 | + If you convey an object code work under this section in, or with, or | |
| 307 | +specifically for use in, a User Product, and the conveying occurs as | |
| 308 | +part of a transaction in which the right of possession and use of the | |
| 309 | +User Product is transferred to the recipient in perpetuity or for a | |
| 310 | +fixed term (regardless of how the transaction is characterized), the | |
| 311 | +Corresponding Source conveyed under this section must be accompanied | |
| 312 | +by the Installation Information. But this requirement does not apply | |
| 313 | +if neither you nor any third party retains the ability to install | |
| 314 | +modified object code on the User Product (for example, the work has | |
| 315 | +been installed in ROM). | |
| 316 | + | |
| 317 | + The requirement to provide Installation Information does not include a | |
| 318 | +requirement to continue to provide support service, warranty, or updates | |
| 319 | +for a work that has been modified or installed by the recipient, or for | |
| 320 | +the User Product in which it has been modified or installed. Access to a | |
| 321 | +network may be denied when the modification itself materially and | |
| 322 | +adversely affects the operation of the network or violates the rules and | |
| 323 | +protocols for communication across the network. | |
| 324 | + | |
| 325 | + Corresponding Source conveyed, and Installation Information provided, | |
| 326 | +in accord with this section must be in a format that is publicly | |
| 327 | +documented (and with an implementation available to the public in | |
| 328 | +source code form), and must require no special password or key for | |
| 329 | +unpacking, reading or copying. | |
| 330 | + | |
| 331 | + 7. Additional Terms. | |
| 332 | + | |
| 333 | + "Additional permissions" are terms that supplement the terms of this | |
| 334 | +License by making exceptions from one or more of its conditions. | |
| 335 | +Additional permissions that are applicable to the entire Program shall | |
| 336 | +be treated as though they were included in this License, to the extent | |
| 337 | +that they are valid under applicable law. If additional permissions | |
| 338 | +apply only to part of the Program, that part may be used separately | |
| 339 | +under those permissions, but the entire Program remains governed by | |
| 340 | +this License without regard to the additional permissions. | |
| 341 | + | |
| 342 | + When you convey a copy of a covered work, you may at your option | |
| 343 | +remove any additional permissions from that copy, or from any part of | |
| 344 | +it. (Additional permissions may be written to require their own | |
| 345 | +removal in certain cases when you modify the work.) You may place | |
| 346 | +additional permissions on material, added by you to a covered work, | |
| 347 | +for which you have or can give appropriate copyright permission. | |
| 348 | + | |
| 349 | + Notwithstanding any other provision of this License, for material you | |
| 350 | +add to a covered work, you may (if authorized by the copyright holders of | |
| 351 | +that material) supplement the terms of this License with terms: | |
| 352 | + | |
| 353 | + a) Disclaiming warranty or limiting liability differently from the | |
| 354 | + terms of sections 15 and 16 of this License; or | |
| 355 | + | |
| 356 | + b) Requiring preservation of specified reasonable legal notices or | |
| 357 | + author attributions in that material or in the Appropriate Legal | |
| 358 | + Notices displayed by works containing it; or | |
| 359 | + | |
| 360 | + c) Prohibiting misrepresentation of the origin of that material, or | |
| 361 | + requiring that modified versions of such material be marked in | |
| 362 | + reasonable ways as different from the original version; or | |
| 363 | + | |
| 364 | + d) Limiting the use for publicity purposes of names of licensors or | |
| 365 | + authors of the material; or | |
| 366 | + | |
| 367 | + e) Declining to grant rights under trademark law for use of some | |
| 368 | + trade names, trademarks, or service marks; or | |
| 369 | + | |
| 370 | + f) Requiring indemnification of licensors and authors of that | |
| 371 | + material by anyone who conveys the material (or modified versions of | |
| 372 | + it) with contractual assumptions of liability to the recipient, for | |
| 373 | + any liability that these contractual assumptions directly impose on | |
| 374 | + those licensors and authors. | |
| 375 | + | |
| 376 | + All other non-permissive additional terms are considered "further | |
| 377 | +restrictions" within the meaning of section 10. If the Program as you | |
| 378 | +received it, or any part of it, contains a notice stating that it is | |
| 379 | +governed by this License along with a term that is a further | |
| 380 | +restriction, you may remove that term. If a license document contains | |
| 381 | +a further restriction but permits relicensing or conveying under this | |
| 382 | +License, you may add to a covered work material governed by the terms | |
| 383 | +of that license document, provided that the further restriction does | |
| 384 | +not survive such relicensing or conveying. | |
| 385 | + | |
| 386 | + If you add terms to a covered work in accord with this section, you | |
| 387 | +must place, in the relevant source files, a statement of the | |
| 388 | +additional terms that apply to those files, or a notice indicating | |
| 389 | +where to find the applicable terms. | |
| 390 | + | |
| 391 | + Additional terms, permissive or non-permissive, may be stated in the | |
| 392 | +form of a separately written license, or stated as exceptions; | |
| 393 | +the above requirements apply either way. | |
| 394 | + | |
| 395 | + 8. Termination. | |
| 396 | + | |
| 397 | + You may not propagate or modify a covered work except as expressly | |
| 398 | +provided under this License. Any attempt otherwise to propagate or | |
| 399 | +modify it is void, and will automatically terminate your rights under | |
| 400 | +this License (including any patent licenses granted under the third | |
| 401 | +paragraph of section 11). | |
| 402 | + | |
| 403 | + However, if you cease all violation of this License, then your | |
| 404 | +license from a particular copyright holder is reinstated (a) | |
| 405 | +provisionally, unless and until the copyright holder explicitly and | |
| 406 | +finally terminates your license, and (b) permanently, if the copyright | |
| 407 | +holder fails to notify you of the violation by some reasonable means | |
| 408 | +prior to 60 days after the cessation. | |
| 409 | + | |
| 410 | + Moreover, your license from a particular copyright holder is | |
| 411 | +reinstated permanently if the copyright holder notifies you of the | |
| 412 | +violation by some reasonable means, this is the first time you have | |
| 413 | +received notice of violation of this License (for any work) from that | |
| 414 | +copyright holder, and you cure the violation prior to 30 days after | |
| 415 | +your receipt of the notice. | |
| 416 | + | |
| 417 | + Termination of your rights under this section does not terminate the | |
| 418 | +licenses of parties who have received copies or rights from you under | |
| 419 | +this License. If your rights have been terminated and not permanently | |
| 420 | +reinstated, you do not qualify to receive new licenses for the same | |
| 421 | +material under section 10. | |
| 422 | + | |
| 423 | + 9. Acceptance Not Required for Having Copies. | |
| 424 | + | |
| 425 | + You are not required to accept this License in order to receive or | |
| 426 | +run a copy of the Program. Ancillary propagation of a covered work | |
| 427 | +occurring solely as a consequence of using peer-to-peer transmission | |
| 428 | +to receive a copy likewise does not require acceptance. However, | |
| 429 | +nothing other than this License grants you permission to propagate or | |
| 430 | +modify any covered work. These actions infringe copyright if you do | |
| 431 | +not accept this License. Therefore, by modifying or propagating a | |
| 432 | +covered work, you indicate your acceptance of this License to do so. | |
| 433 | + | |
| 434 | + 10. Automatic Licensing of Downstream Recipients. | |
| 435 | + | |
| 436 | + Each time you convey a covered work, the recipient automatically | |
| 437 | +receives a license from the original licensors, to run, modify and | |
| 438 | +propagate that work, subject to this License. You are not responsible | |
| 439 | +for enforcing compliance by third parties with this License. | |
| 440 | + | |
| 441 | + An "entity transaction" is a transaction transferring control of an | |
| 442 | +organization, or substantially all assets of one, or subdividing an | |
| 443 | +organization, or merging organizations. If propagation of a covered | |
| 444 | +work results from an entity transaction, each party to that | |
| 445 | +transaction who receives a copy of the work also receives whatever | |
| 446 | +licenses to the work the party's predecessor in interest had or could | |
| 447 | +give under the previous paragraph, plus a right to possession of the | |
| 448 | +Corresponding Source of the work from the predecessor in interest, if | |
| 449 | +the predecessor has it or can get it with reasonable efforts. | |
| 450 | + | |
| 451 | + You may not impose any further restrictions on the exercise of the | |
| 452 | +rights granted or affirmed under this License. For example, you may | |
| 453 | +not impose a license fee, royalty, or other charge for exercise of | |
| 454 | +rights granted under this License, and you may not initiate litigation | |
| 455 | +(including a cross-claim or counterclaim in a lawsuit) alleging that | |
| 456 | +any patent claim is infringed by making, using, selling, offering for | |
| 457 | +sale, or importing the Program or any portion of it. | |
| 458 | + | |
| 459 | + 11. Patents. | |
| 460 | + | |
| 461 | + A "contributor" is a copyright holder who authorizes use under this | |
| 462 | +License of the Program or a work on which the Program is based. The | |
| 463 | +work thus licensed is called the contributor's "contributor version". | |
| 464 | + | |
| 465 | + A contributor's "essential patent claims" are all patent claims | |
| 466 | +owned or controlled by the contributor, whether already acquired or | |
| 467 | +hereafter acquired, that would be infringed by some manner, permitted | |
| 468 | +by this License, of making, using, or selling its contributor version, | |
| 469 | +but do not include claims that would be infringed only as a | |
| 470 | +consequence of further modification of the contributor version. For | |
| 471 | +purposes of this definition, "control" includes the right to grant | |
| 472 | +patent sublicenses in a manner consistent with the requirements of | |
| 473 | +this License. | |
| 474 | + | |
| 475 | + Each contributor grants you a non-exclusive, worldwide, royalty-free | |
| 476 | +patent license under the contributor's essential patent claims, to | |
| 477 | +make, use, sell, offer for sale, import and otherwise run, modify and | |
| 478 | +propagate the contents of its contributor version. | |
| 479 | + | |
| 480 | + In the following three paragraphs, a "patent license" is any express | |
| 481 | +agreement or commitment, however denominated, not to enforce a patent | |
| 482 | +(such as an express permission to practice a patent or covenant not to | |
| 483 | +sue for patent infringement). To "grant" such a patent license to a | |
| 484 | +party means to make such an agreement or commitment not to enforce a | |
| 485 | +patent against the party. | |
| 486 | + | |
| 487 | + If you convey a covered work, knowingly relying on a patent license, | |
| 488 | +and the Corresponding Source of the work is not available for anyone | |
| 489 | +to copy, free of charge and under the terms of this License, through a | |
| 490 | +publicly available network server or other readily accessible means, | |
| 491 | +then you must either (1) cause the Corresponding Source to be so | |
| 492 | +available, or (2) arrange to deprive yourself of the benefit of the | |
| 493 | +patent license for this particular work, or (3) arrange, in a manner | |
| 494 | +consistent with the requirements of this License, to extend the patent | |
| 495 | +license to downstream recipients. "Knowingly relying" means you have | |
| 496 | +actual knowledge that, but for the patent license, your conveying the | |
| 497 | +covered work in a country, or your recipient's use of the covered work | |
| 498 | +in a country, would infringe one or more identifiable patents in that | |
| 499 | +country that you have reason to believe are valid. | |
| 500 | + | |
| 501 | + If, pursuant to or in connection with a single transaction or | |
| 502 | +arrangement, you convey, or propagate by procuring conveyance of, a | |
| 503 | +covered work, and grant a patent license to some of the parties | |
| 504 | +receiving the covered work authorizing them to use, propagate, modify | |
| 505 | +or convey a specific copy of the covered work, then the patent license | |
| 506 | +you grant is automatically extended to all recipients of the covered | |
| 507 | +work and works based on it. | |
| 508 | + | |
| 509 | + A patent license is "discriminatory" if it does not include within | |
| 510 | +the scope of its coverage, prohibits the exercise of, or is | |
| 511 | +conditioned on the non-exercise of one or more of the rights that are | |
| 512 | +specifically granted under this License. You may not convey a covered | |
| 513 | +work if you are a party to an arrangement with a third party that is | |
| 514 | +in the business of distributing software, under which you make payment | |
| 515 | +to the third party based on the extent of your activity of conveying | |
| 516 | +the work, and under which the third party grants, to any of the | |
| 517 | +parties who would receive the covered work from you, a discriminatory | |
| 518 | +patent license (a) in connection with copies of the covered work | |
| 519 | +conveyed by you (or copies made from those copies), or (b) primarily | |
| 520 | +for and in connection with specific products or compilations that | |
| 521 | +contain the covered work, unless you entered into that arrangement, | |
| 522 | +or that patent license was granted, prior to 28 March 2007. | |
| 523 | + | |
| 524 | + Nothing in this License shall be construed as excluding or limiting | |
| 525 | +any implied license or other defenses to infringement that may | |
| 526 | +otherwise be available to you under applicable patent law. | |
| 527 | + | |
| 528 | + 12. No Surrender of Others' Freedom. | |
| 529 | + | |
| 530 | + If conditions are imposed on you (whether by court order, agreement or | |
| 531 | +otherwise) that contradict the conditions of this License, they do not | |
| 532 | +excuse you from the conditions of this License. If you cannot convey a | |
| 533 | +covered work so as to satisfy simultaneously your obligations under this | |
| 534 | +License and any other pertinent obligations, then as a consequence you may | |
| 535 | +not convey it at all. For example, if you agree to terms that obligate you | |
| 536 | +to collect a royalty for further conveying from those to whom you convey | |
| 537 | +the Program, the only way you could satisfy both those terms and this | |
| 538 | +License would be to refrain entirely from conveying the Program. | |
| 539 | + | |
| 540 | + 13. Remote Network Interaction; Use with the GNU General Public License. | |
| 541 | + | |
| 542 | + Notwithstanding any other provision of this License, if you modify the | |
| 543 | +Program, your modified version must prominently offer all users | |
| 544 | +interacting with it remotely through a computer network (if your version | |
| 545 | +supports such interaction) an opportunity to receive the Corresponding | |
| 546 | +Source of your version by providing access to the Corresponding Source | |
| 547 | +from a network server at no charge, through some standard or customary | |
| 548 | +means of facilitating copying of software. This Corresponding Source | |
| 549 | +shall include the Corresponding Source for any work covered by version 3 | |
| 550 | +of the GNU General Public License that is incorporated pursuant to the | |
| 551 | +following paragraph. | |
| 552 | + | |
| 553 | + Notwithstanding any other provision of this License, you have | |
| 554 | +permission to link or combine any covered work with a work licensed | |
| 555 | +under version 3 of the GNU General Public License into a single | |
| 556 | +combined work, and to convey the resulting work. The terms of this | |
| 557 | +License will continue to apply to the part which is the covered work, | |
| 558 | +but the work with which it is combined will remain governed by version | |
| 559 | +3 of the GNU General Public License. | |
| 560 | + | |
| 561 | + 14. Revised Versions of this License. | |
| 562 | + | |
| 563 | + The Free Software Foundation may publish revised and/or new versions of | |
| 564 | +the GNU Affero General Public License from time to time. Such new versions | |
| 565 | +will be similar in spirit to the present version, but may differ in detail to | |
| 566 | +address new problems or concerns. | |
| 567 | + | |
| 568 | + Each version is given a distinguishing version number. If the | |
| 569 | +Program specifies that a certain numbered version of the GNU Affero General | |
| 570 | +Public License "or any later version" applies to it, you have the | |
| 571 | +option of following the terms and conditions either of that numbered | |
| 572 | +version or of any later version published by the Free Software | |
| 573 | +Foundation. If the Program does not specify a version number of the | |
| 574 | +GNU Affero General Public License, you may choose any version ever published | |
| 575 | +by the Free Software Foundation. | |
| 576 | + | |
| 577 | + If the Program specifies that a proxy can decide which future | |
| 578 | +versions of the GNU Affero General Public License can be used, that proxy's | |
| 579 | +public statement of acceptance of a version permanently authorizes you | |
| 580 | +to choose that version for the Program. | |
| 581 | + | |
| 582 | + Later license versions may give you additional or different | |
| 583 | +permissions. However, no additional obligations are imposed on any | |
| 584 | +author or copyright holder as a result of your choosing to follow a | |
| 585 | +later version. | |
| 586 | + | |
| 587 | + 15. Disclaimer of Warranty. | |
| 588 | + | |
| 589 | + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY | |
| 590 | +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT | |
| 591 | +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY | |
| 592 | +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, | |
| 593 | +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR | |
| 594 | +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM | |
| 595 | +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF | |
| 596 | +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. | |
| 597 | + | |
| 598 | + 16. Limitation of Liability. | |
| 599 | + | |
| 600 | + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING | |
| 601 | +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS | |
| 602 | +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY | |
| 603 | +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE | |
| 604 | +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF | |
| 605 | +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD | |
| 606 | +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), | |
| 607 | +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF | |
| 608 | +SUCH DAMAGES. | |
| 609 | + | |
| 610 | + 17. Interpretation of Sections 15 and 16. | |
| 611 | + | |
| 612 | + If the disclaimer of warranty and limitation of liability provided | |
| 613 | +above cannot be given local legal effect according to their terms, | |
| 614 | +reviewing courts shall apply local law that most closely approximates | |
| 615 | +an absolute waiver of all civil liability in connection with the | |
| 616 | +Program, unless a warranty or assumption of liability accompanies a | |
| 617 | +copy of the Program in return for a fee. | |
| 618 | + | |
| 619 | + END OF TERMS AND CONDITIONS | |
| 620 | + | |
| 621 | + How to Apply These Terms to Your New Programs | |
| 622 | + | |
| 623 | + If you develop a new program, and you want it to be of the greatest | |
| 624 | +possible use to the public, the best way to achieve this is to make it | |
| 625 | +free software which everyone can redistribute and change under these terms. | |
| 626 | + | |
| 627 | + To do so, attach the following notices to the program. It is safest | |
| 628 | +to attach them to the start of each source file to most effectively | |
| 629 | +state the exclusion of warranty; and each file should have at least | |
| 630 | +the "copyright" line and a pointer to where the full notice is found. | |
| 631 | + | |
| 632 | + <one line to give the program's name and a brief idea of what it does.> | |
| 633 | + Copyright (C) <year> <name of author> | |
| 634 | + | |
| 635 | + This program is free software: you can redistribute it and/or modify | |
| 636 | + it under the terms of the GNU Affero General Public License as published by | |
| 637 | + the Free Software Foundation, either version 3 of the License, or | |
| 638 | + (at your option) any later version. | |
| 639 | + | |
| 640 | + This program is distributed in the hope that it will be useful, | |
| 641 | + but WITHOUT ANY WARRANTY; without even the implied warranty of | |
| 642 | + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
| 643 | + GNU Affero General Public License for more details. | |
| 644 | + | |
| 645 | + You should have received a copy of the GNU Affero General Public License | |
| 646 | + along with this program. If not, see <https://www.gnu.org/licenses/>. | |
| 647 | + | |
| 648 | +Also add information on how to contact you by electronic and paper mail. | |
| 649 | + | |
| 650 | + If your software can interact with users remotely through a computer | |
| 651 | +network, you should also make sure that it provides a way for users to | |
| 652 | +get its source. For example, if your program is a web application, its | |
| 653 | +interface could display a "Source" link that leads users to an archive | |
| 654 | +of the code. There are many ways you could offer source, and different | |
| 655 | +solutions will be better for different programs; see section 13 for the | |
| 656 | +specific requirements. | |
| 657 | + | |
| 658 | + You should also get your employer (if you work as a programmer) or school, | |
| 659 | +if any, to sign a "copyright disclaimer" for the program, if necessary. | |
| 660 | +For more information on this, and how to apply and follow the GNU AGPL, see | |
| 661 | +<https://www.gnu.org/licenses/>. |
README.md| @@ -0,0 +1,299 @@ | ||
| 1 | +# forge | |
| 2 | + | |
| 3 | +forge is a self-hosted git forge written in Rust. It is a working name, and the project is not yet released. | |
| 4 | + | |
| 5 | +It runs as a single process (`forge`) that serves the web UI, git over HTTPS (smart HTTP), and git over SSH. Repositories are plain bare git repositories on disk. Account and repo metadata live in a SQLite/Turso database. | |
| 6 | + | |
| 7 | +## Status | |
| 8 | + | |
| 9 | +forge is at **Milestone 2** ("repo browsing and raw files"). It currently provides: | |
| 10 | + | |
| 11 | +- local accounts (argon2id passwords), with the first user created as admin | |
| 12 | +- sessions, CSRF protection on every POST, and a `__Host-` session cookie | |
| 13 | +- SSH public keys and personal access tokens (`forge_...`) | |
| 14 | +- creating repos, with public or private visibility, collaborators, and an archive flag | |
| 15 | +- clone and push over HTTPS and SSH | |
| 16 | +- basic branch protection: the default branch cannot be deleted or force-pushed, and archived repos reject pushes | |
| 17 | +- repo browsing: the repo home page with the rendered README, directory listings with the last commit per entry, file views with syntax highlighting and `#L12` line anchors, Markdown rendering with a source toggle, commit history (paginated), commit views with diffs, branch and tag lists, and `.tar.gz` / `.zip` archives | |
| 18 | +- raw files served from a separate user-content origin (see [Raw user content](#raw-user-content)) | |
| 19 | + | |
| 20 | +Not yet: issues, pull requests, webhooks, mirrors, CI. See [Roadmap](#roadmap). | |
| 21 | + | |
| 22 | +## Requirements | |
| 23 | + | |
| 24 | +- Rust stable, edition 2024 | |
| 25 | +- git 2.38 or newer (boot refuses to start with an older git) | |
| 26 | + | |
| 27 | +## Quick start | |
| 28 | + | |
| 29 | +1. Copy the example config. forge reads `config/forge.toml` by default. | |
| 30 | + | |
| 31 | + ```sh | |
| 32 | + cp config/forge.example.toml config/forge.toml | |
| 33 | + ``` | |
| 34 | + | |
| 35 | +2. Start the server. | |
| 36 | + | |
| 37 | + ```sh | |
| 38 | + cargo run -p forge-web | |
| 39 | + ``` | |
| 40 | + | |
| 41 | +3. Create the admin account. Either open http://127.0.0.1:7660/setup (it works only while there are zero users), or use the CLI: | |
| 42 | + | |
| 43 | + ```sh | |
| 44 | + cargo run -p forge-web -- admin create-user <username> --admin | |
| 45 | + ``` | |
| 46 | + | |
| 47 | + The CLI reads the password from `FORGE_PASSWORD` if set, or prompts for it twice. Passwords must be at least 12 characters. The first user is always an admin, even without `--admin`. | |
| 48 | + | |
| 49 | +4. Add an SSH key at `/settings/keys`. Use an ed25519 key: | |
| 50 | + | |
| 51 | + ```sh | |
| 52 | + ssh-keygen -t ed25519 | |
| 53 | + ``` | |
| 54 | + | |
| 55 | + Paste the contents of the `.pub` file. RSA keys are rejected with a message asking for ed25519. The reason is that `ssh-rsa` keys sign with SHA-1, and forge disables SHA-1 signatures. ECDSA and security-key (`sk-`) variants are also accepted by the form. | |
| 56 | + | |
| 57 | +5. To change only the capitalization of your username (for example `tahinli` to `Tahinli`), open `/settings/profile`. Any other change is refused. The instance operator can do the same from a shell with `cargo run -p forge-web -- admin set-username <current> <NewCase>`. | |
| 58 | + | |
| 59 | +6. Create an access token at `/settings/tokens`. The token starts with `forge_` and is shown once. Store it somewhere safe. | |
| 60 | + | |
| 61 | +7. Account security lives at `/settings/security`: | |
| 62 | + - **Password:** change it with the current password. The new one needs 10 to 1024 characters. Your other sessions are signed out; the one you are using stays signed in. Wrong current passwords count toward the same per-user login limit as failed logins. | |
| 63 | + - **Email:** change it with the current password. The address must contain one `@`, have no spaces, and be at most 254 characters. | |
| 64 | + - **Two-factor authentication (TOTP):** click "Enable 2FA", add the secret or `otpauth://` link to an authenticator app, and confirm with a 6-digit code. Ten single-use recovery codes are then shown once, so save them. Turning 2FA off needs the password and a code. Regenerating recovery codes needs a code. Codes are TOTP (RFC 6238, SHA-1, 6 digits, 30-second steps), and a code from one step either side of the current time is accepted. Each code works only once. With 2FA on, a correct password leads to `/login/2fa`, which asks for a code or a recovery code. The pending login lasts 5 minutes. Each user gets 10 second-factor attempts per 15 minutes. | |
| 65 | + - **Git over HTTPS is unaffected by 2FA.** It authenticates with access tokens (step 6), not with passwords or TOTP codes. Revoke a token at `/settings/tokens` if it leaks. | |
| 66 | + | |
| 67 | +7. Create a repo at `/new`, then push over either transport: | |
| 68 | + | |
| 69 | + ```sh | |
| 70 | + # SSH | |
| 71 | + git remote add origin ssh://git@127.0.0.1:2222/<owner>/<repo>.git | |
| 72 | + git push -u origin main | |
| 73 | + | |
| 74 | + # HTTPS: username and token as the password | |
| 75 | + git remote add origin http://127.0.0.1:7660/<owner>/<repo>.git | |
| 76 | + git push -u origin main | |
| 77 | + ``` | |
| 78 | + | |
| 79 | +## Configuration | |
| 80 | + | |
| 81 | +The file is TOML. Every key in `config/forge.example.toml` is listed below. Paths are relative to the working directory unless absolute. | |
| 82 | + | |
| 83 | +| Key | Example value | Meaning | | |
| 84 | +|---|---|---| | |
| 85 | +| `database` | `"./data/forge.db"` | SQLite/Turso database file. `admin create-user` creates its parent directory. | | |
| 86 | +| `data_dir` | `"./data"` | Root for repos, instance key, gitconfig, hooks, and sockets. Created with mode 0700. | | |
| 87 | +| `listen` | `"127.0.0.1:7660"` | HTTP listen address for the web UI and smart HTTP. | | |
| 88 | +| `base_url` | `"http://127.0.0.1:7660"` | Public base URL used in links and clone URLs. Set this to your public `https://` URL in production. | | |
| 89 | +| `user_content_base_url` | `"http://127.0.0.1:7661"` | Separate origin for raw user content. Set it to a different registrable domain in production (see [Raw user content](#raw-user-content)). | | |
| 90 | +| `user_content_listen` | `"127.0.0.1:7661"` | Address of the listener that serves raw user content. Your reverse proxy forwards the user-content hostname to it. | | |
| 91 | +| `ssh_listen` | `"0.0.0.0:2222"` | SSH listen address for git over SSH. | | |
| 92 | +| `ssh_public_host` | `"127.0.0.1"` | Host shown in SSH clone URLs. | | |
| 93 | +| `ssh_public_port` | `2222` | Port shown in SSH clone URLs. | | |
| 94 | +| `registration` | `"closed"` | Self-registration policy: `closed`, `invite`, or `open`. Default is `closed`. M1 has no sign-up page, so accounts come from `/setup`, the CLI, or `/admin/users`. | | |
| 95 | +| `trusted_proxies` | `[]` | Addresses or CIDR networks (for example `["127.0.0.1"]`) whose `X-Forwarded-For` header is trusted. Set it to the address your reverse proxy connects from. Empty means the TCP peer is the client, so a spoofed header cannot change the client IP. Used for login and git throttling. | | |
| 96 | + | |
| 97 | +Production config normally lives at `/etc/forge/forge.toml` (see the systemd unit below). | |
| 98 | + | |
| 99 | +### Command line and environment | |
| 100 | + | |
| 101 | +```text | |
| 102 | +forge [--config <path>] [serve | hook <pre-receive|post-receive> | runner <register|run|check> ... | admin create-user <username> [--admin] | admin set-username <current> <NewCase> | admin reset-password <username> | admin trash-list | admin trash-purge --older-than-days <N> | admin audit-purge --older-than-days <N> | admin delete-user <username> [--yes] | admin backup --out <DIR> [--include-key] [--repos-only | --db-only] | admin backup-verify <PATH> | admin fsck [--full] | admin lfs-gc | admin restore --from <SET> --data-dir <NEW_DIR>] | |
| 103 | +``` | |
| 104 | + | |
| 105 | +- `--config <path>` sets the config file. Precedence: `--config`, then `FORGE_CONFIG`, then `config/forge.toml`. | |
| 106 | +- `serve` (the default when no subcommand is given) starts the server. | |
| 107 | +- `hook <stage>` is called by git hook shims. You do not run it by hand. | |
| 108 | +- `admin create-user <username> [--admin]` creates a user from the CLI. | |
| 109 | +- `admin set-username <current> <NewCase>` changes only the capitalization of a user's username (`current` matches any case). Example: `forge admin set-username tahinli Tahinli`. | |
| 110 | +- `admin reset-password <username>` sets a random 20-character temporary password, prints it once, and signs the user out of every session. Give the password to the user, who should change it at `/settings/security`. Nothing else changes. Use it when a user is locked out of their account. Two-factor settings are not changed, so the user still needs a code. | |
| 111 | +- `admin trash-list` lists deleted repos in `data_dir/trash`, oldest first, as `<deleted_at>\t<repo id>\t<path>`. | |
| 112 | +- `admin trash-purge --older-than-days N` permanently removes trash entries deleted at least N days ago. Nothing else is removed, and this cannot be undone. | |
| 113 | +- `FORGE_PASSWORD` supplies the password for `admin create-user` without a prompt. | |
| 114 | +- `RUST_LOG` sets the log filter (default `info`). Logs go to stderr. | |
| 115 | +- `GET /healthz` returns `ok <build sha>`. | |
| 116 | + | |
| 117 | +## Data layout | |
| 118 | + | |
| 119 | +Everything under `data_dir`: | |
| 120 | + | |
| 121 | +| Path | Purpose | | |
| 122 | +|---|---| | |
| 123 | +| `forge.db`, `forge.db-wal`, `forge.db-shm` | Metadata database (users, sessions, keys, tokens, repos). Sits next to `database` in the example config. | | |
| 124 | +| `forge.key` | Instance key. Encrypts sealed values. Created on first boot. | | |
| 125 | +| `ssh_host_ed25519_key` | SSH host key. Created on first boot. | | |
| 126 | +| `gitconfig` | Managed gitconfig written at boot. Do not edit by hand, it is rewritten at boot. | | |
| 127 | +| `hooks/` | `pre-receive` and `post-receive` shims that run `forge hook <name>`. Rewritten at boot. | | |
| 128 | +| `repos/ab/cd/<id>.git` | Bare repositories. `<id>` is the lowercased ULID; the path does not depend on the repo name. | | |
| 129 | +| `internal.sock` | Unix socket used by git hooks to reach the server. Mode 0600. Removed on shutdown. | | |
| 130 | +| `trash/<id>-<unix_ts>.git` | Repos deleted from the settings page (`/{owner}/{repo}/settings`). Each is a plain bare repository, moved here by a rename. The database rows are gone, so the repo no longer appears in forge. | | |
| 131 | + | |
| 132 | +### Recovering a deleted repo | |
| 133 | + | |
| 134 | +A deleted repo is not erased at once. Its bare directory is moved to `data_dir/trash/`, and only `admin trash-purge` removes it. To recover it, list the trash with `forge admin trash-list` and clone from the directory, which works without the server: | |
| 135 | + | |
| 136 | +```sh | |
| 137 | +git clone --mirror /var/lib/forge/trash/<id>-<unix_ts>.git recovered.git | |
| 138 | +``` | |
| 139 | + | |
| 140 | +The clone restores all branches and tags. The owner, visibility, description and collaborators are not restored, so re-create the repo under the same name and push the mirror to it with `git push --mirror`. | |
| 141 | + | |
| 142 | +### Backups | |
| 143 | + | |
| 144 | +See [`docs/backup.md`](docs/backup.md) for what a backup set contains, how to ship it off-site, how to restore, and the quarterly restore drill. | |
| 145 | + | |
| 146 | +- **`forge.key` must be backed up separately and stored safely, offline.** Without it, sealed values cannot be decrypted, and they are lost. Keep it out of the same backup set as the database if you can. | |
| 147 | +- **Never copy a live `forge.db`** (or its `-wal`/`-shm` files) with `cp` or `rsync`. Take a consistent snapshot with `VACUUM INTO`, for example from the `sqlite3` shell while forge is running: | |
| 148 | + | |
| 149 | + ```sql | |
| 150 | + VACUUM INTO '/var/backups/forge/forge-2026-10-15.db'; | |
| 151 | + ``` | |
| 152 | + | |
| 153 | +- **Repositories**: do not copy bare repos while pushes are in progress. Use `git bundle create --all` or `git push --mirror` to a copy. | |
| 154 | +- **Unix socket path length**: `data_dir/internal.sock` must be shorter than 108 bytes, the unix socket path limit. Boot fails with an error otherwise. Choose a short `data_dir` (for example `/var/lib/forge`). | |
| 155 | + | |
| 156 | +## Deployment | |
| 157 | + | |
| 158 | +### systemd | |
| 159 | + | |
| 160 | +`deploy/forge.service` runs `/usr/local/bin/forge serve` as a dedicated `forge` user, with `FORGE_CONFIG=/etc/forge/forge.toml`, working directory `/var/lib/forge`, and a hardened sandbox (`ProtectSystem=strict`, `/var/lib/forge` and, if present, `/var/backups/forge` writable, no capabilities, `UMask=0077`). | |
| 161 | + | |
| 162 | +Typical setup: | |
| 163 | + | |
| 164 | +```sh | |
| 165 | +sudo useradd --system --home /var/lib/forge --shell /usr/sbin/nologin forge | |
| 166 | +sudo install -d -o forge -g forge /var/lib/forge /etc/forge | |
| 167 | +sudo install -m 0640 -g forge config/forge.example.toml /etc/forge/forge.toml | |
| 168 | +sudo install -m 0755 target/release/forge /usr/local/bin/forge | |
| 169 | +sudo install -m 0644 deploy/forge.service /etc/systemd/system/forge.service | |
| 170 | +sudo systemctl daemon-reload && sudo systemctl enable forge | |
| 171 | +``` | |
| 172 | + | |
| 173 | +Edit `/etc/forge/forge.toml` before starting: | |
| 174 | + | |
| 175 | +- `base_url`: public `https://` URL. | |
| 176 | +- `user_content_base_url`: a separate registrable domain, not a subdomain of `base_url`. | |
| 177 | +- `ssh_public_host` and `ssh_public_port`: public hostname and port. | |
| 178 | +- `database` and `data_dir`: absolute paths under `/var/lib/forge`. | |
| 179 | +- `trusted_proxies = ["127.0.0.1"]` when the reverse proxy runs on this host. Otherwise every client shares the proxy's IP for login and git throttling. | |
| 180 | +- `[backup] dir`: under `/var/lib/forge` or `/var/backups/forge`. The unit makes only those writable. Create the latter with `sudo install -d -o forge -g forge -m 0700 /var/backups/forge`. | |
| 181 | + | |
| 182 | +Create the admin before starting. `/setup` lets the first visitor claim the admin account while there are no users, so create the admin before the reverse proxy exposes the site. | |
| 183 | + | |
| 184 | +```sh | |
| 185 | +sudo -u forge FORGE_CONFIG=/etc/forge/forge.toml /usr/local/bin/forge admin create-user <name> --admin | |
| 186 | +sudo systemctl start forge | |
| 187 | +``` | |
| 188 | + | |
| 189 | +Open the SSH port (2222 by default) in the firewall. | |
| 190 | + | |
| 191 | +### Reverse proxy and TLS | |
| 192 | + | |
| 193 | +Run forge behind a TLS reverse proxy for any non-localhost use. The session cookie is named `__Host-forge_session` and is marked `Secure`, so browsers send it only over HTTPS. Logging in over plain HTTP outside localhost will not work. Set `base_url` to the public HTTPS URL. | |
| 194 | + | |
| 195 | +Git routes need a proxy that does not buffer or time out early. The example below targets [Ferron](https://ferron.sh) 3.0.0-rc.7, the newest release found (a pre-release; the latest stable, 2.8.1, was not checked against this config). Ferron uses its own `ferron.conf` format, not KDL. | |
| 196 | + | |
| 197 | +- Buffering: `buffer_request` and `buffer_response` are off by default. The example sets both to `0` so they stay off, because git smart HTTP streams packs in both directions. | |
| 198 | +- Timeouts: `connection_timeout` (default 2s) and `idle_timeout` (default 60s) are per upstream. The example raises `idle_timeout` to `3600s`, because `git upload-pack` can stay silent while it builds a pack. Ferron's proxy docs list no total-request timeout, so there is no separate read/send timeout to raise. | |
| 199 | +- Body size: Ferron's docs describe no request body size limit, so there is nothing to disable. Do not add one on git paths (`/<owner>/<repo>/git-receive-pack`, `git-upload-pack`). | |
| 200 | +- Forwarded headers: Ferron sets `X-Forwarded-For`, `X-Forwarded-Proto`, `X-Real-IP` and `Forwarded` itself and keeps the incoming `Host`, so the example adds no header lines. | |
| 201 | +- SSH: port 2222 is not proxied. Ferron handles HTTP(S) only, so forge serves SSH directly on `ssh_listen`. Open that port on the host. | |
| 202 | + | |
| 203 | +### Brute-force throttling | |
| 204 | + | |
| 205 | +Login and git HTTP authentication are throttled in memory, per process, over a 15 minute sliding window: | |
| 206 | + | |
| 207 | +- Login: 10 failed attempts per username, and 30 per client IP. A throttled attempt gets HTTP 429 with the message "Too many attempts. Try again later.", and the password is not checked. A successful login clears that username's counter. | |
| 208 | +- Git HTTP (Basic auth with a token): 30 failed attempts per client IP. The next request gets HTTP 429 with `Retry-After`. Anonymous requests and successful logins are not counted. | |
| 209 | +- The client IP is the TCP peer, unless the peer is in `trusted_proxies`. Then it is the right-most `X-Forwarded-For` address that is not a trusted proxy. IPv6 addresses are counted per /64. | |
| 210 | +- Throttling events are logged at `warn` with the IP and the folded username. Counters are lost on restart and are not shared between instances. | |
| 211 | + | |
| 212 | +### Raw user content | |
| 213 | + | |
| 214 | +forge runs a third listener, `user_content_listen` (default `127.0.0.1:7661`). It serves only the raw files of repos, on the origin `user_content_base_url`. It has its own explicit routes and does not share the app's router. | |
| 215 | + | |
| 216 | +Put it on its own registrable domain in production, for example `usercontent.example.net` next to `forge.example.com`. Browsers treat that as a separate site, so a file a user uploads cannot run script against the app origin. The raw origin is also built to be inert: | |
| 217 | + | |
| 218 | +- No cookies are read or set on it, so app sessions never reach it. | |
| 219 | +- Every response has `Content-Security-Policy: default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox`, `X-Content-Type-Options: nosniff`, and `Referrer-Policy: no-referrer`. | |
| 220 | +- SVG, HTML, XML and other non-image binaries are sent with `Content-Disposition: attachment`. | |
| 221 | + | |
| 222 | +Private repos need a signed link. The app puts a `?token=` on raw links for private repos, and that token is valid for 10 minutes. Public repos need no token. A missing, hidden, or bad token gets 404. | |
| 223 | + | |
| 224 | +Example Ferron 3.0.0-rc.7 config (`ferron.conf`) with two hostnames. A bare host block already gets automatic ACME TLS; the `tls` block only sets the account contact: | |
| 225 | + | |
| 226 | +``` | |
| 227 | +forge.example.com { | |
| 228 | + tls { | |
| 229 | + provider acme | |
| 230 | + challenge http-01 | |
| 231 | + contact admin@example.com | |
| 232 | + } | |
| 233 | + buffer_request 0 | |
| 234 | + buffer_response 0 | |
| 235 | + proxy { | |
| 236 | + upstream http://127.0.0.1:7660 { | |
| 237 | + idle_timeout "3600s" | |
| 238 | + } | |
| 239 | + } | |
| 240 | +} | |
| 241 | + | |
| 242 | +usercontent.example.net { | |
| 243 | + tls { | |
| 244 | + provider acme | |
| 245 | + challenge http-01 | |
| 246 | + contact admin@example.com | |
| 247 | + } | |
| 248 | + buffer_request 0 | |
| 249 | + buffer_response 0 | |
| 250 | + proxy { | |
| 251 | + upstream http://127.0.0.1:7661 | |
| 252 | + } | |
| 253 | +} | |
| 254 | +``` | |
| 255 | + | |
| 256 | +The two hostnames are different registrable domains, so the raw origin is a separate site with no app cookies. | |
| 257 | + | |
| 258 | +With that setup, set `base_url = "https://forge.example.com"` and `user_content_base_url = "https://usercontent.example.net"`. | |
| 259 | + | |
| 260 | +### Limits | |
| 261 | + | |
| 262 | +- Blob view: the first 1 MiB of a file is shown. Larger files are truncated, with a link to the raw file. | |
| 263 | +- Syntax highlighting: skipped for files over 512 KiB or 20,000 lines. Those are shown without highlighting. | |
| 264 | +- Raw files: up to 8 MiB are read into memory and sent. Larger files are streamed. | |
| 265 | +- Commit diffs: at most 300 files, 2,000 lines per file, and 20,000 lines in total. Anything past that is cut off with a notice. | |
| 266 | + | |
| 267 | +Compression is currently **off for every route**. Git smart-HTTP responses must not be compressed, and the server cannot yet switch compression off per route, so it is disabled globally (`TODO(M1)` in `boot.rs`). Let the proxy do no compression on git paths either. | |
| 268 | + | |
| 269 | +## Security notes | |
| 270 | + | |
| 271 | +From `docs/architecture.md`. These are the properties forge is designed for. Not all are fully implemented in M1. | |
| 272 | + | |
| 273 | +- Authorization goes through a single function, `authorize()`. A private repo that the actor cannot see returns 404, so its existence is not revealed. | |
| 274 | +- Passwords use argon2id. Tokens and session tokens are stored as SHA-256 hashes and compared in constant time. Tokens are shown once and kept out of URLs and logs. | |
| 275 | +- SSH keys must match exactly. `ssh-rsa` with SHA-1 is disabled, and the SSH server accepts only `GIT_PROTOCOL` from env requests. Handshake and inactivity timeouts apply. | |
| 276 | +- No shell is used. SSH exec commands are parsed against an allow-list, and git is started with explicit arguments. | |
| 277 | +- Git runs with `receive.fsckObjects=true`, and hooks are fail-closed: if the internal socket is unreachable, the push is rejected. | |
| 278 | +- Users cannot write hooks or repo-local config. Submodules are not recursed. | |
| 279 | +- Run as a dedicated service user, and keep the git binary patched. | |
| 280 | +- Keep `forge.key` and `data_dir` private. Back up `forge.key` separately. | |
| 281 | + | |
| 282 | +## Roadmap | |
| 283 | + | |
| 284 | +From `docs/architecture.md` (section 10). | |
| 285 | + | |
| 286 | +- **M1: clone and push work.** Accounts, SSH keys, tokens, repo create, smart HTTP and SSH, pre-receive policy, minimal UI. | |
| 287 | +- **M2 (current): repo browsing and raw domain.** Tree, blob, commit and diff views; README rendering; syntax highlighting; raw files on `user_content_base_url`. | |
| 288 | +- **M3: issues.** Issues, comments, labels, milestones, with permissions through `authorize()`. | |
| 289 | +- **M4: pull requests.** PR refs, mergeability via `merge-tree`, reviews, merge strategies. | |
| 290 | +- **M5: webhooks, push mirrors, replication, backups, export.** Including `VACUUM INTO` snapshots, integrity checks, and restore drills. | |
| 291 | +- **M6: custom CI runner and commit-status API.** Required checks enforced on push as well as PR merge. | |
| 292 | + | |
| 293 | +## License | |
| 294 | + | |
| 295 | +forge is licensed under the GNU Affero General Public License, version 3 or later (AGPL-3.0-or-later). | |
| 296 | + | |
| 297 | +### Fonts | |
| 298 | + | |
| 299 | +The web UI bundles two fonts, served from the forge origin under `/assets/fonts/`: Figtree (UI) and IBM Plex Mono (code). Both are licensed under the SIL Open Font License 1.1. The license texts are in `style/fonts/` and are also served at `/assets/fonts/Figtree-OFL.txt` and `/assets/fonts/IBMPlexMono-OFL.txt`. Sources, pinned versions and checksums are listed in `style/fonts/README.md`. |
config/forge.example.toml| @@ -0,0 +1,112 @@ | ||
| 1 | +# forge example configuration. Copy to forge.toml and adjust. | |
| 2 | +# Development defaults; production values belong in /etc/forge/forge.toml. | |
| 3 | + | |
| 4 | +# SQLite/Turso database file. | |
| 5 | +database = "./data/forge.db" | |
| 6 | +# Root for repos, instance key, gitconfig, hooks and sockets. | |
| 7 | +data_dir = "./data" | |
| 8 | +# HTTP listen address for the web UI and smart-HTTP. | |
| 9 | +listen = "127.0.0.1:7660" | |
| 10 | +# Public base URL used in links and clone URLs. | |
| 11 | +base_url = "http://127.0.0.1:7660" | |
| 12 | +# Separate origin for raw user content (Milestone 2). | |
| 13 | +user_content_base_url = "http://127.0.0.1:7661" | |
| 14 | +# Listener for raw user files. It is served on a separate origin, so in production | |
| 15 | +# user_content_base_url should use a separate registrable domain (not a subdomain of | |
| 16 | +# base_url), which keeps user-uploaded content from running with the main site's cookies. | |
| 17 | +user_content_listen = "127.0.0.1:7661" | |
| 18 | +# SSH listen address for git over SSH. | |
| 19 | +ssh_listen = "0.0.0.0:2222" | |
| 20 | +# Host and port shown in SSH clone URLs. | |
| 21 | +ssh_public_host = "127.0.0.1" | |
| 22 | +ssh_public_port = 2222 | |
| 23 | +# Self-registration: "closed" | "invite" | "open". | |
| 24 | +registration = "closed" | |
| 25 | +# Proxies whose X-Forwarded-For is trusted for client IPs (login and git throttling). Addresses or CIDRs. | |
| 26 | +# Set to ["127.0.0.1"] when running behind a local reverse proxy. Empty: the TCP peer is the client. | |
| 27 | +trusted_proxies = [] | |
| 28 | + | |
| 29 | +# Outbound webhooks. Internal destinations (loopback, private, link-local, CGNAT, ...) are refused. | |
| 30 | +# [webhooks] | |
| 31 | +# Allow http:// hook URLs (https only by default). | |
| 32 | +# allow_http = false | |
| 33 | +# Exact hostnames or CIDR networks that may be reached even if they resolve to internal addresses. | |
| 34 | +# allowed_hosts = ["hooks.internal.example", "10.20.0.0/16"] | |
| 35 | + | |
| 36 | +# Scheduled backups (see docs/backup.md). Empty dir disables the scheduler. | |
| 37 | +# [backup] | |
| 38 | +# Directory that receives the backup sets. Ship it off-site with restic or rclone. | |
| 39 | +# dir = "/var/backups/forge" | |
| 40 | +# Hours between full backup sets. | |
| 41 | +# interval_hours = 24 | |
| 42 | +# Newest complete sets kept in dir. Older complete sets are deleted after a good run. | |
| 43 | +# keep = 7 | |
| 44 | +# Optional healthchecks.io style URL: pinged with /start, then the URL on success or /fail on error. | |
| 45 | +# The URL may hold a token, so the admin page shows only its host. | |
| 46 | +# healthcheck_url = "" | |
| 47 | +# Days between git fsck of every repo and the database integrity check. | |
| 48 | +# fsck_interval_days = 7 | |
| 49 | + | |
| 50 | +# Release settings. | |
| 51 | +# [releases] | |
| 52 | +# Largest accepted release asset, in MiB. | |
| 53 | +# max_asset_mb = 512 | |
| 54 | + | |
| 55 | +# Standby replication (see docs/backup.md). Empty target disables it. When target is set, | |
| 56 | +# identity_file and known_hosts_file are required. | |
| 57 | +# [replication] | |
| 58 | +# [user@]host:/absolute/base of the standby account. Empty disables replication. | |
| 59 | +# target = "" | |
| 60 | +# SSH port of the standby host. | |
| 61 | +# ssh_port = 22 | |
| 62 | +# Private key of the standby account (mode 0600). Required when target is set. | |
| 63 | +# identity_file = "" | |
| 64 | +# Pinned host keys of the standby. Required when target is set. StrictHostKeyChecking is always yes. | |
| 65 | +# known_hosts_file = "" | |
| 66 | +# Minutes between database snapshots shipped to the standby. Must be at least 1. | |
| 67 | +# db_snapshot_minutes = 15 | |
| 68 | +# Minutes between reconcile passes (refs checksums, deleted repos, healthcheck). Must be at least 1. | |
| 69 | +# reconcile_minutes = 60 | |
| 70 | +# Optional URL pinged after each reconcile pass. Empty disables it. | |
| 71 | +# healthcheck_url = "" | |
| 72 | + | |
| 73 | +# Git LFS limits. | |
| 74 | +# [lfs] | |
| 75 | +# Largest accepted LFS object, in MiB. | |
| 76 | +# max_object_mb = 2048 | |
| 77 | + | |
| 78 | +# CI limits and retention. | |
| 79 | +# [ci] | |
| 80 | +# Whether CI runs at all. When false, pushes and pull requests create no runs. | |
| 81 | +# enabled = true | |
| 82 | +# Largest job log, in MiB. Past it the log is truncated. | |
| 83 | +# max_log_mb = 16 | |
| 84 | +# Largest artifact, in MiB. | |
| 85 | +# max_artifact_mb = 512 | |
| 86 | +# Artifacts a single job may upload. | |
| 87 | +# max_artifacts_per_job = 50 | |
| 88 | +# Days a job log is kept. | |
| 89 | +# log_retention_days = 90 | |
| 90 | +# Days an artifact is kept. | |
| 91 | +# artifact_retention_days = 30 | |
| 92 | +# Hours a runner registration token stays valid. | |
| 93 | +# registration_token_hours = 24 | |
| 94 | + | |
| 95 | +# Security advisories (opt-in). When enabled, the forge downloads the OSV dumps for ecosystems and scans each repo's lockfiles. | |
| 96 | +# [advisories] | |
| 97 | +# Whether the advisory download and the lockfile scans run at all. | |
| 98 | +# enabled = false | |
| 99 | +# OSV ecosystems to download and scan: any of "crates.io", "npm", "Go". | |
| 100 | +# ecosystems = ["crates.io", "npm", "Go"] | |
| 101 | +# Hours before a downloaded dump is fetched again. | |
| 102 | +# refresh_hours = 24 | |
| 103 | + | |
| 104 | +# Outgoing mail transport. The SMTP server, sender and password are set on /admin/mail and stored in the database. | |
| 105 | +# [mail] | |
| 106 | +# smtp sends through the configured server. log writes each message to <data_dir>/mail-log/ instead, for development and tests. | |
| 107 | +# transport = "smtp" | |
| 108 | + | |
| 109 | +# Organization rules. | |
| 110 | +# [orgs] | |
| 111 | +# Who may create organizations: "all" (any signed-in person) or "admin" (instance admins only). | |
| 112 | +# creation = "all" |
crates/forge-ci/Cargo.toml| @@ -0,0 +1,15 @@ | ||
| 1 | +[package] | |
| 2 | +name = "forge-ci" | |
| 3 | +description = "CI workflow config, runner protocol and log masking for forge" | |
| 4 | +version.workspace = true | |
| 5 | +edition.workspace = true | |
| 6 | +license.workspace = true | |
| 7 | +publish = false | |
| 8 | + | |
| 9 | +[dependencies] | |
| 10 | +base64 = { workspace = true } | |
| 11 | +globset = { workspace = true } | |
| 12 | +serde = { workspace = true } | |
| 13 | +serde_json = { workspace = true } | |
| 14 | +thiserror = { workspace = true } | |
| 15 | +toml = { workspace = true } |
crates/forge-ci/src/config.rs| @@ -0,0 +1,662 @@ | ||
| 1 | +//! Parser and validator for `.forge/ci.toml`. | |
| 2 | +//! | |
| 3 | +//! ```toml | |
| 4 | +//! [on] | |
| 5 | +//! push = ["main"] | |
| 6 | +//! pull_request = true | |
| 7 | +//! | |
| 8 | +//! [jobs.test] | |
| 9 | +//! image = "docker.io/rust:1" | |
| 10 | +//! run = "cargo test --workspace" | |
| 11 | +//! ``` | |
| 12 | +//! | |
| 13 | +//! Globs (`push`, `tags`, `artifacts`) use git-like semantics: `*` does not match `/`, `**` does. | |
| 14 | + | |
| 15 | +use std::collections::{BTreeMap, BTreeSet}; | |
| 16 | + | |
| 17 | +use globset::{Glob, GlobBuilder}; | |
| 18 | +use serde::{Deserialize, Serialize}; | |
| 19 | + | |
| 20 | +use crate::error::ConfigError; | |
| 21 | + | |
| 22 | +/// Largest accepted `ci.toml`, in bytes. | |
| 23 | +pub const MAX_CONFIG_BYTES: usize = 64 * 1024; | |
| 24 | +/// Largest number of jobs in one workflow. | |
| 25 | +pub const MAX_JOBS: usize = 20; | |
| 26 | +/// Default job timeout. | |
| 27 | +pub const DEFAULT_TIMEOUT_MINUTES: u32 = 60; | |
| 28 | +/// Smallest and largest allowed job timeout. | |
| 29 | +pub const TIMEOUT_RANGE_MINUTES: std::ops::RangeInclusive<u32> = 1..=360; | |
| 30 | + | |
| 31 | +/// A parsed and validated workflow. | |
| 32 | +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] | |
| 33 | +#[serde(deny_unknown_fields)] | |
| 34 | +pub struct CiConfig { | |
| 35 | + /// The `[on]` table. Optional; defaults to manual runs only. | |
| 36 | + #[serde(rename = "on", default)] | |
| 37 | + pub on: Triggers, | |
| 38 | + /// The `[jobs.<name>]` tables, keyed by job name. | |
| 39 | + #[serde(default)] | |
| 40 | + pub jobs: BTreeMap<String, JobSpec>, | |
| 41 | +} | |
| 42 | + | |
| 43 | +/// The `[on]` table. | |
| 44 | +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] | |
| 45 | +#[serde(deny_unknown_fields, default)] | |
| 46 | +pub struct Triggers { | |
| 47 | + /// Branch globs, e.g. `"main"`, `"release/*"`. | |
| 48 | + pub push: Vec<String>, | |
| 49 | + /// Tag globs, e.g. `"v*"`. | |
| 50 | + pub tags: Vec<String>, | |
| 51 | + pub pull_request: bool, | |
| 52 | + /// Defaults to `true`. | |
| 53 | + pub manual: bool, | |
| 54 | +} | |
| 55 | + | |
| 56 | +impl Default for Triggers { | |
| 57 | + fn default() -> Self { | |
| 58 | + Self { | |
| 59 | + push: Vec::new(), | |
| 60 | + tags: Vec::new(), | |
| 61 | + pull_request: false, | |
| 62 | + manual: true, | |
| 63 | + } | |
| 64 | + } | |
| 65 | +} | |
| 66 | + | |
| 67 | +/// One `[jobs.<name>]` table. | |
| 68 | +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] | |
| 69 | +#[serde(deny_unknown_fields)] | |
| 70 | +pub struct JobSpec { | |
| 71 | + pub image: String, | |
| 72 | + pub run: String, | |
| 73 | + #[serde(default)] | |
| 74 | + pub needs: Vec<String>, | |
| 75 | + /// Secret names (`^[A-Z_][A-Z0-9_]{0,63}$`) the job receives. | |
| 76 | + #[serde(default)] | |
| 77 | + pub secrets: Vec<String>, | |
| 78 | + #[serde(default)] | |
| 79 | + pub env: BTreeMap<String, String>, | |
| 80 | + /// Relative globs of files to keep after a successful run. | |
| 81 | + #[serde(default)] | |
| 82 | + pub artifacts: Vec<String>, | |
| 83 | + /// Runner labels; every label must be present on the runner. | |
| 84 | + #[serde(default)] | |
| 85 | + pub runs_on: Vec<String>, | |
| 86 | + #[serde(default = "default_timeout_minutes")] | |
| 87 | + pub timeout_minutes: u32, | |
| 88 | + #[serde(default = "default_network")] | |
| 89 | + pub network: bool, | |
| 90 | +} | |
| 91 | + | |
| 92 | +fn default_timeout_minutes() -> u32 { | |
| 93 | + DEFAULT_TIMEOUT_MINUTES | |
| 94 | +} | |
| 95 | + | |
| 96 | +fn default_network() -> bool { | |
| 97 | + true | |
| 98 | +} | |
| 99 | + | |
| 100 | +/// Something that can start a workflow run. | |
| 101 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 102 | +pub enum Event<'a> { | |
| 103 | + Push { branch: &'a str }, | |
| 104 | + Tag { tag: &'a str }, | |
| 105 | + PullRequest, | |
| 106 | + Manual, | |
| 107 | +} | |
| 108 | + | |
| 109 | +/// Parses and validates a `ci.toml` document. | |
| 110 | +pub fn parse_config(text: &str) -> Result<CiConfig, ConfigError> { | |
| 111 | + if text.len() > MAX_CONFIG_BYTES { | |
| 112 | + return Err(ConfigError::TooLarge { | |
| 113 | + size: text.len(), | |
| 114 | + max: MAX_CONFIG_BYTES, | |
| 115 | + }); | |
| 116 | + } | |
| 117 | + let config: CiConfig = toml::from_str(text).map_err(|e| ConfigError::Toml(e.to_string()))?; | |
| 118 | + config.validate()?; | |
| 119 | + Ok(config) | |
| 120 | +} | |
| 121 | + | |
| 122 | +impl CiConfig { | |
| 123 | + /// Whether an event starts this workflow. | |
| 124 | + pub fn triggered_by(&self, ev: &Event) -> bool { | |
| 125 | + match ev { | |
| 126 | + Event::Push { branch } => matches_any(&self.on.push, branch), | |
| 127 | + Event::Tag { tag } => matches_any(&self.on.tags, tag), | |
| 128 | + Event::PullRequest => self.on.pull_request, | |
| 129 | + Event::Manual => self.on.manual, | |
| 130 | + } | |
| 131 | + } | |
| 132 | + | |
| 133 | + /// Job names in dependency order. Ties are broken by name, so the order is stable. | |
| 134 | + /// Jobs in a cycle, or with unknown needs, are left out. [`parse_config`] rejects both. | |
| 135 | + pub fn job_order(&self) -> Vec<&str> { | |
| 136 | + let mut remaining: BTreeMap<&str, BTreeSet<&str>> = self | |
| 137 | + .jobs | |
| 138 | + .iter() | |
| 139 | + .map(|(name, job)| { | |
| 140 | + ( | |
| 141 | + name.as_str(), | |
| 142 | + job.needs.iter().map(String::as_str).collect(), | |
| 143 | + ) | |
| 144 | + }) | |
| 145 | + .collect(); | |
| 146 | + let mut done: BTreeSet<&str> = BTreeSet::new(); | |
| 147 | + let mut order = Vec::with_capacity(self.jobs.len()); | |
| 148 | + loop { | |
| 149 | + let next = remaining | |
| 150 | + .iter() | |
| 151 | + .find(|(_, needs)| needs.iter().all(|n| done.contains(n))) | |
| 152 | + .map(|(name, _)| *name); | |
| 153 | + let Some(name) = next else { break }; | |
| 154 | + remaining.remove(name); | |
| 155 | + done.insert(name); | |
| 156 | + order.push(name); | |
| 157 | + } | |
| 158 | + order | |
| 159 | + } | |
| 160 | + | |
| 161 | + fn validate(&self) -> Result<(), ConfigError> { | |
| 162 | + if self.jobs.is_empty() { | |
| 163 | + return Err(ConfigError::NoJobs); | |
| 164 | + } | |
| 165 | + if self.jobs.len() > MAX_JOBS { | |
| 166 | + return Err(ConfigError::TooManyJobs { | |
| 167 | + count: self.jobs.len(), | |
| 168 | + max: MAX_JOBS, | |
| 169 | + }); | |
| 170 | + } | |
| 171 | + for pattern in &self.on.push { | |
| 172 | + validate_trigger_glob("branch", pattern)?; | |
| 173 | + } | |
| 174 | + for pattern in &self.on.tags { | |
| 175 | + validate_trigger_glob("tag", pattern)?; | |
| 176 | + } | |
| 177 | + for (name, job) in &self.jobs { | |
| 178 | + validate_job(name, job, &self.jobs)?; | |
| 179 | + } | |
| 180 | + let order = self.job_order(); | |
| 181 | + if order.len() != self.jobs.len() { | |
| 182 | + let job = self | |
| 183 | + .jobs | |
| 184 | + .keys() | |
| 185 | + .find(|name| !order.contains(&name.as_str())) | |
| 186 | + .cloned() | |
| 187 | + .unwrap_or_default(); | |
| 188 | + return Err(ConfigError::Cycle { job }); | |
| 189 | + } | |
| 190 | + Ok(()) | |
| 191 | + } | |
| 192 | +} | |
| 193 | + | |
| 194 | +fn matches_any(globs: &[String], value: &str) -> bool { | |
| 195 | + globs | |
| 196 | + .iter() | |
| 197 | + .any(|pattern| build_glob(pattern).is_ok_and(|g| g.compile_matcher().is_match(value))) | |
| 198 | +} | |
| 199 | + | |
| 200 | +fn build_glob(pattern: &str) -> Result<Glob, globset::Error> { | |
| 201 | + GlobBuilder::new(pattern).literal_separator(true).build() | |
| 202 | +} | |
| 203 | + | |
| 204 | +fn validate_trigger_glob(kind: &'static str, pattern: &str) -> Result<(), ConfigError> { | |
| 205 | + let invalid = |reason: String| ConfigError::InvalidTriggerGlob { | |
| 206 | + kind, | |
| 207 | + pattern: pattern.to_string(), | |
| 208 | + reason, | |
| 209 | + }; | |
| 210 | + if pattern.is_empty() { | |
| 211 | + return Err(invalid("pattern is empty".into())); | |
| 212 | + } | |
| 213 | + build_glob(pattern) | |
| 214 | + .map(|_| ()) | |
| 215 | + .map_err(|e| invalid(e.to_string())) | |
| 216 | +} | |
| 217 | + | |
| 218 | +/// `^[a-z0-9][a-z0-9_-]{0,39}$` | |
| 219 | +fn is_job_name(name: &str) -> bool { | |
| 220 | + let Some((first, rest)) = name.as_bytes().split_first() else { | |
| 221 | + return false; | |
| 222 | + }; | |
| 223 | + rest.len() <= 39 | |
| 224 | + && (first.is_ascii_lowercase() || first.is_ascii_digit()) | |
| 225 | + && rest | |
| 226 | + .iter() | |
| 227 | + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || *b == b'_' || *b == b'-') | |
| 228 | +} | |
| 229 | + | |
| 230 | +/// `^[A-Z_][A-Z0-9_]{0,63}$`, used for secret and env names. | |
| 231 | +fn is_env_name(name: &str) -> bool { | |
| 232 | + let Some((first, rest)) = name.as_bytes().split_first() else { | |
| 233 | + return false; | |
| 234 | + }; | |
| 235 | + rest.len() <= 63 | |
| 236 | + && (first.is_ascii_uppercase() || *first == b'_') | |
| 237 | + && rest | |
| 238 | + .iter() | |
| 239 | + .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || *b == b'_') | |
| 240 | +} | |
| 241 | + | |
| 242 | +fn is_runner_label(label: &str) -> bool { | |
| 243 | + (1..=64).contains(&label.len()) | |
| 244 | + && label | |
| 245 | + .bytes() | |
| 246 | + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.') | |
| 247 | +} | |
| 248 | + | |
| 249 | +fn validate_artifact(job: &str, pattern: &str) -> Result<(), ConfigError> { | |
| 250 | + let invalid = |reason: &str| ConfigError::InvalidArtifactGlob { | |
| 251 | + job: job.to_string(), | |
| 252 | + pattern: pattern.to_string(), | |
| 253 | + reason: reason.to_string(), | |
| 254 | + }; | |
| 255 | + if pattern.is_empty() { | |
| 256 | + return Err(invalid("pattern is empty")); | |
| 257 | + } | |
| 258 | + if pattern.starts_with('/') || pattern.starts_with('\\') { | |
| 259 | + return Err(invalid("must be relative")); | |
| 260 | + } | |
| 261 | + if pattern.split(['/', '\\']).any(|part| part == "..") { | |
| 262 | + return Err(invalid("must not contain \"..\"")); | |
| 263 | + } | |
| 264 | + build_glob(pattern) | |
| 265 | + .map(|_| ()) | |
| 266 | + .map_err(|e| invalid(&e.to_string())) | |
| 267 | +} | |
| 268 | + | |
| 269 | +fn validate_job( | |
| 270 | + name: &str, | |
| 271 | + job: &JobSpec, | |
| 272 | + all: &BTreeMap<String, JobSpec>, | |
| 273 | +) -> Result<(), ConfigError> { | |
| 274 | + if !is_job_name(name) { | |
| 275 | + return Err(ConfigError::InvalidJobName(name.to_string())); | |
| 276 | + } | |
| 277 | + if job.image.is_empty() || job.image.chars().any(char::is_whitespace) { | |
| 278 | + return Err(ConfigError::InvalidImage { | |
| 279 | + job: name.to_string(), | |
| 280 | + }); | |
| 281 | + } | |
| 282 | + if job.run.trim().is_empty() { | |
| 283 | + return Err(ConfigError::EmptyRun { | |
| 284 | + job: name.to_string(), | |
| 285 | + }); | |
| 286 | + } | |
| 287 | + for need in &job.needs { | |
| 288 | + if !all.contains_key(need) { | |
| 289 | + return Err(ConfigError::UnknownNeed { | |
| 290 | + job: name.to_string(), | |
| 291 | + need: need.clone(), | |
| 292 | + }); | |
| 293 | + } | |
| 294 | + } | |
| 295 | + for secret in &job.secrets { | |
| 296 | + if !is_env_name(secret) { | |
| 297 | + return Err(ConfigError::InvalidSecretName { | |
| 298 | + job: name.to_string(), | |
| 299 | + name: secret.clone(), | |
| 300 | + }); | |
| 301 | + } | |
| 302 | + } | |
| 303 | + for key in job.env.keys() { | |
| 304 | + if !is_env_name(key) { | |
| 305 | + return Err(ConfigError::InvalidEnvName { | |
| 306 | + job: name.to_string(), | |
| 307 | + name: key.clone(), | |
| 308 | + }); | |
| 309 | + } | |
| 310 | + } | |
| 311 | + for pattern in &job.artifacts { | |
| 312 | + validate_artifact(name, pattern)?; | |
| 313 | + } | |
| 314 | + for label in &job.runs_on { | |
| 315 | + if !is_runner_label(label) { | |
| 316 | + return Err(ConfigError::InvalidRunsOn { | |
| 317 | + job: name.to_string(), | |
| 318 | + label: label.clone(), | |
| 319 | + }); | |
| 320 | + } | |
| 321 | + } | |
| 322 | + if !TIMEOUT_RANGE_MINUTES.contains(&job.timeout_minutes) { | |
| 323 | + return Err(ConfigError::InvalidTimeout { | |
| 324 | + job: name.to_string(), | |
| 325 | + value: job.timeout_minutes, | |
| 326 | + }); | |
| 327 | + } | |
| 328 | + Ok(()) | |
| 329 | +} | |
| 330 | + | |
| 331 | +#[cfg(test)] | |
| 332 | +mod tests { | |
| 333 | + use super::*; | |
| 334 | + | |
| 335 | + const EXAMPLE: &str = r#" | |
| 336 | +[on] | |
| 337 | +push = ["main"] | |
| 338 | +pull_request = true | |
| 339 | + | |
| 340 | +[jobs.test] | |
| 341 | +image = "docker.io/rust:1" | |
| 342 | +run = "cargo test --workspace" | |
| 343 | + | |
| 344 | +[jobs.lint] | |
| 345 | +image = "docker.io/rust:1" | |
| 346 | +needs = ["test"] | |
| 347 | +secrets = ["DEPLOY_KEY"] | |
| 348 | +run = ''' | |
| 349 | +cargo clippy -- -D warnings | |
| 350 | +cargo fmt --check | |
| 351 | +''' | |
| 352 | +artifacts = ["target/release/forge"] | |
| 353 | +"#; | |
| 354 | + | |
| 355 | + fn job_toml(body: &str) -> String { | |
| 356 | + format!("[jobs.build]\nimage = \"docker.io/rust:1\"\nrun = \"cargo build\"\n{body}\n") | |
| 357 | + } | |
| 358 | + | |
| 359 | + fn err_of(text: &str) -> ConfigError { | |
| 360 | + match parse_config(text) { | |
| 361 | + Ok(_) => panic!("expected an error for:\n{text}"), | |
| 362 | + Err(e) => e, | |
| 363 | + } | |
| 364 | + } | |
| 365 | + | |
| 366 | + #[test] | |
| 367 | + fn parses_the_contract_example() { | |
| 368 | + let cfg = parse_config(EXAMPLE).unwrap(); | |
| 369 | + assert_eq!(cfg.on.push, vec!["main".to_string()]); | |
| 370 | + assert!(cfg.on.pull_request); | |
| 371 | + assert!(cfg.on.manual, "manual defaults to true"); | |
| 372 | + assert!(cfg.on.tags.is_empty()); | |
| 373 | + assert_eq!(cfg.jobs.len(), 2); | |
| 374 | + let lint = &cfg.jobs["lint"]; | |
| 375 | + assert_eq!(lint.needs, vec!["test".to_string()]); | |
| 376 | + assert_eq!(lint.secrets, vec!["DEPLOY_KEY".to_string()]); | |
| 377 | + assert_eq!(lint.artifacts, vec!["target/release/forge".to_string()]); | |
| 378 | + assert!(lint.run.contains("cargo clippy -- -D warnings")); | |
| 379 | + assert_eq!(lint.timeout_minutes, 60); | |
| 380 | + assert!(lint.network); | |
| 381 | + assert!(lint.runs_on.is_empty()); | |
| 382 | + assert!(lint.env.is_empty()); | |
| 383 | + } | |
| 384 | + | |
| 385 | + #[test] | |
| 386 | + fn manual_defaults_to_true_and_on_is_optional() { | |
| 387 | + let cfg = parse_config(&job_toml("")).unwrap(); | |
| 388 | + assert!(cfg.on.manual); | |
| 389 | + assert!(!cfg.on.pull_request); | |
| 390 | + assert!(cfg.on.push.is_empty()); | |
| 391 | + } | |
| 392 | + | |
| 393 | + #[test] | |
| 394 | + fn manual_can_be_disabled() { | |
| 395 | + let cfg = parse_config(&format!("[on]\nmanual = false\n{}", job_toml(""))).unwrap(); | |
| 396 | + assert!(!cfg.triggered_by(&Event::Manual)); | |
| 397 | + } | |
| 398 | + | |
| 399 | + #[test] | |
| 400 | + fn unknown_keys_are_rejected() { | |
| 401 | + assert!(matches!( | |
| 402 | + err_of(&format!("[on]\nschedule = true\n{}", job_toml(""))), | |
| 403 | + ConfigError::Toml(_) | |
| 404 | + )); | |
| 405 | + assert!(matches!( | |
| 406 | + err_of(&job_toml("bogus = 1")), | |
| 407 | + ConfigError::Toml(_) | |
| 408 | + )); | |
| 409 | + assert!(matches!(err_of("[other]\nx = 1\n"), ConfigError::Toml(_))); | |
| 410 | + } | |
| 411 | + | |
| 412 | + #[test] | |
| 413 | + fn no_jobs_is_an_error() { | |
| 414 | + assert_eq!(err_of("[on]\nmanual = true\n"), ConfigError::NoJobs); | |
| 415 | + } | |
| 416 | + | |
| 417 | + #[test] | |
| 418 | + fn too_many_jobs_is_an_error() { | |
| 419 | + let mut text = String::new(); | |
| 420 | + for i in 0..=MAX_JOBS { | |
| 421 | + text.push_str(&format!("[jobs.j{i}]\nimage = \"img\"\nrun = \"true\"\n")); | |
| 422 | + } | |
| 423 | + assert_eq!( | |
| 424 | + err_of(&text), | |
| 425 | + ConfigError::TooManyJobs { | |
| 426 | + count: MAX_JOBS + 1, | |
| 427 | + max: MAX_JOBS | |
| 428 | + } | |
| 429 | + ); | |
| 430 | + let mut ok = String::new(); | |
| 431 | + for i in 0..MAX_JOBS { | |
| 432 | + ok.push_str(&format!("[jobs.j{i}]\nimage = \"img\"\nrun = \"true\"\n")); | |
| 433 | + } | |
| 434 | + assert!(parse_config(&ok).is_ok()); | |
| 435 | + } | |
| 436 | + | |
| 437 | + #[test] | |
| 438 | + fn file_size_cap() { | |
| 439 | + let mut text = job_toml(""); | |
| 440 | + text.push_str(&"#".repeat(MAX_CONFIG_BYTES)); | |
| 441 | + assert!(matches!( | |
| 442 | + err_of(&text), | |
| 443 | + ConfigError::TooLarge { max, .. } if max == MAX_CONFIG_BYTES | |
| 444 | + )); | |
| 445 | + // Exactly at the limit is accepted. | |
| 446 | + let mut at_limit = job_toml(""); | |
| 447 | + let pad = MAX_CONFIG_BYTES - at_limit.len(); | |
| 448 | + at_limit.push_str(&"#".repeat(pad)); | |
| 449 | + assert_eq!(at_limit.len(), MAX_CONFIG_BYTES); | |
| 450 | + assert!(parse_config(&at_limit).is_ok()); | |
| 451 | + } | |
| 452 | + | |
| 453 | + #[test] | |
| 454 | + fn invalid_job_names() { | |
| 455 | + for name in ["Test", "-x", "_x", "a b", "a.b", ""] { | |
| 456 | + let text = format!("[jobs.\"{name}\"]\nimage = \"img\"\nrun = \"true\"\n"); | |
| 457 | + assert!( | |
| 458 | + matches!(err_of(&text), ConfigError::InvalidJobName(_)), | |
| 459 | + "name {name:?} should be rejected" | |
| 460 | + ); | |
| 461 | + } | |
| 462 | + let long = "a".repeat(41); | |
| 463 | + let text = format!("[jobs.{long}]\nimage = \"img\"\nrun = \"true\"\n"); | |
| 464 | + assert!(matches!(err_of(&text), ConfigError::InvalidJobName(_))); | |
| 465 | + let max = "a".repeat(40); | |
| 466 | + let text = format!("[jobs.{max}]\nimage = \"img\"\nrun = \"true\"\n"); | |
| 467 | + assert!(parse_config(&text).is_ok()); | |
| 468 | + for name in ["a", "0", "a-b_c9"] { | |
| 469 | + let text = format!("[jobs.{name}]\nimage = \"img\"\nrun = \"true\"\n"); | |
| 470 | + assert!( | |
| 471 | + parse_config(&text).is_ok(), | |
| 472 | + "name {name:?} should be accepted" | |
| 473 | + ); | |
| 474 | + } | |
| 475 | + } | |
| 476 | + | |
| 477 | + #[test] | |
| 478 | + fn unknown_need() { | |
| 479 | + let text = job_toml("needs = [\"ghost\"]"); | |
| 480 | + assert_eq!( | |
| 481 | + err_of(&text), | |
| 482 | + ConfigError::UnknownNeed { | |
| 483 | + job: "build".into(), | |
| 484 | + need: "ghost".into() | |
| 485 | + } | |
| 486 | + ); | |
| 487 | + } | |
| 488 | + | |
| 489 | + #[test] | |
| 490 | + fn cycles_are_detected() { | |
| 491 | + let text = "[jobs.a]\nimage = \"i\"\nrun = \"r\"\nneeds = [\"b\"]\n\ | |
| 492 | + [jobs.b]\nimage = \"i\"\nrun = \"r\"\nneeds = [\"a\"]\n\ | |
| 493 | + [jobs.c]\nimage = \"i\"\nrun = \"r\"\n"; | |
| 494 | + assert_eq!(err_of(text), ConfigError::Cycle { job: "a".into() }); | |
| 495 | + let self_loop = job_toml("needs = [\"build\"]"); | |
| 496 | + assert_eq!( | |
| 497 | + err_of(&self_loop), | |
| 498 | + ConfigError::Cycle { | |
| 499 | + job: "build".into() | |
| 500 | + } | |
| 501 | + ); | |
| 502 | + } | |
| 503 | + | |
| 504 | + #[test] | |
| 505 | + fn image_validation() { | |
| 506 | + let empty = "[jobs.b]\nimage = \"\"\nrun = \"r\"\n"; | |
| 507 | + assert!(matches!(err_of(empty), ConfigError::InvalidImage { .. })); | |
| 508 | + let space = "[jobs.b]\nimage = \"a b\"\nrun = \"r\"\n"; | |
| 509 | + assert!(matches!(err_of(space), ConfigError::InvalidImage { .. })); | |
| 510 | + } | |
| 511 | + | |
| 512 | + #[test] | |
| 513 | + fn run_validation() { | |
| 514 | + let blank = "[jobs.b]\nimage = \"i\"\nrun = \" \\n \"\n"; | |
| 515 | + assert!(matches!(err_of(blank), ConfigError::EmptyRun { .. })); | |
| 516 | + } | |
| 517 | + | |
| 518 | + #[test] | |
| 519 | + fn secret_and_env_names() { | |
| 520 | + let long = "A".repeat(65); | |
| 521 | + for bad in ["deploy", "1KEY", "A-B", "", long.as_str()] { | |
| 522 | + let text = job_toml(&format!("secrets = [{bad:?}]")); | |
| 523 | + assert!( | |
| 524 | + matches!(err_of(&text), ConfigError::InvalidSecretName { .. }), | |
| 525 | + "secret {bad:?} should be rejected" | |
| 526 | + ); | |
| 527 | + } | |
| 528 | + let text = job_toml("secrets = [\"_OK_1\"]"); | |
| 529 | + assert!(parse_config(&text).is_ok()); | |
| 530 | + let text = job_toml("[jobs.build.env]\n\"lower\" = \"x\""); | |
| 531 | + assert!(matches!(err_of(&text), ConfigError::InvalidEnvName { .. })); | |
| 532 | + let text = job_toml("[jobs.build.env]\nRUST_LOG = \"info\""); | |
| 533 | + assert!(parse_config(&text).is_ok()); | |
| 534 | + } | |
| 535 | + | |
| 536 | + #[test] | |
| 537 | + fn artifact_validation() { | |
| 538 | + for bad in ["/etc/passwd", "..", "a/../b", "\\\\x", "", "a/[b"] { | |
| 539 | + let text = job_toml(&format!("artifacts = [{bad:?}]")); | |
| 540 | + assert!( | |
| 541 | + matches!(err_of(&text), ConfigError::InvalidArtifactGlob { .. }), | |
| 542 | + "artifact {bad:?} should be rejected" | |
| 543 | + ); | |
| 544 | + } | |
| 545 | + let text = job_toml("artifacts = [\"target/release/*\", \"dist/**/*.tar\"]"); | |
| 546 | + assert!(parse_config(&text).is_ok()); | |
| 547 | + } | |
| 548 | + | |
| 549 | + #[test] | |
| 550 | + fn runs_on_validation() { | |
| 551 | + let text = job_toml("runs_on = [\"linux\", \"x86_64\"]"); | |
| 552 | + assert_eq!( | |
| 553 | + parse_config(&text).unwrap().jobs["build"].runs_on, | |
| 554 | + vec!["linux".to_string(), "x86_64".to_string()] | |
| 555 | + ); | |
| 556 | + let text = job_toml("runs_on = [\"has space\"]"); | |
| 557 | + assert!(matches!(err_of(&text), ConfigError::InvalidRunsOn { .. })); | |
| 558 | + let text = job_toml("runs_on = [\"\"]"); | |
| 559 | + assert!(matches!(err_of(&text), ConfigError::InvalidRunsOn { .. })); | |
| 560 | + } | |
| 561 | + | |
| 562 | + #[test] | |
| 563 | + fn timeout_validation() { | |
| 564 | + assert_eq!( | |
| 565 | + parse_config(&job_toml("timeout_minutes = 1")).unwrap().jobs["build"].timeout_minutes, | |
| 566 | + 1 | |
| 567 | + ); | |
| 568 | + assert_eq!( | |
| 569 | + parse_config(&job_toml("timeout_minutes = 360")) | |
| 570 | + .unwrap() | |
| 571 | + .jobs["build"] | |
| 572 | + .timeout_minutes, | |
| 573 | + 360 | |
| 574 | + ); | |
| 575 | + for bad in [0, 361] { | |
| 576 | + let text = job_toml(&format!("timeout_minutes = {bad}")); | |
| 577 | + assert_eq!( | |
| 578 | + err_of(&text), | |
| 579 | + ConfigError::InvalidTimeout { | |
| 580 | + job: "build".into(), | |
| 581 | + value: bad | |
| 582 | + } | |
| 583 | + ); | |
| 584 | + } | |
| 585 | + } | |
| 586 | + | |
| 587 | + #[test] | |
| 588 | + fn network_defaults_to_true() { | |
| 589 | + assert!(parse_config(&job_toml("")).unwrap().jobs["build"].network); | |
| 590 | + assert!(!parse_config(&job_toml("network = false")).unwrap().jobs["build"].network); | |
| 591 | + } | |
| 592 | + | |
| 593 | + #[test] | |
| 594 | + fn branch_glob_triggers() { | |
| 595 | + let text = format!("[on]\npush = [\"main\", \"release/*\"]\n{}", job_toml("")); | |
| 596 | + let cfg = parse_config(&text).unwrap(); | |
| 597 | + assert!(cfg.triggered_by(&Event::Push { branch: "main" })); | |
| 598 | + assert!(cfg.triggered_by(&Event::Push { | |
| 599 | + branch: "release/1.0" | |
| 600 | + })); | |
| 601 | + assert!(!cfg.triggered_by(&Event::Push { | |
| 602 | + branch: "release/1.0/x" | |
| 603 | + })); | |
| 604 | + assert!(!cfg.triggered_by(&Event::Push { branch: "mainx" })); | |
| 605 | + assert!(!cfg.triggered_by(&Event::Push { branch: "feature" })); | |
| 606 | + assert!(!cfg.triggered_by(&Event::Tag { tag: "v1" })); | |
| 607 | + } | |
| 608 | + | |
| 609 | + #[test] | |
| 610 | + fn tag_glob_triggers() { | |
| 611 | + let text = format!("[on]\ntags = [\"v*\"]\n{}", job_toml("")); | |
| 612 | + let cfg = parse_config(&text).unwrap(); | |
| 613 | + assert!(cfg.triggered_by(&Event::Tag { tag: "v1.2.3" })); | |
| 614 | + assert!(!cfg.triggered_by(&Event::Tag { tag: "release-1" })); | |
| 615 | + assert!(!cfg.triggered_by(&Event::Push { branch: "v1" })); | |
| 616 | + } | |
| 617 | + | |
| 618 | + #[test] | |
| 619 | + fn invalid_trigger_globs_are_rejected() { | |
| 620 | + let text = format!("[on]\npush = [\"[\"]\n{}", job_toml("")); | |
| 621 | + assert!(matches!( | |
| 622 | + err_of(&text), | |
| 623 | + ConfigError::InvalidTriggerGlob { kind: "branch", .. } | |
| 624 | + )); | |
| 625 | + let text = format!("[on]\ntags = [\"\"]\n{}", job_toml("")); | |
| 626 | + assert!(matches!( | |
| 627 | + err_of(&text), | |
| 628 | + ConfigError::InvalidTriggerGlob { kind: "tag", .. } | |
| 629 | + )); | |
| 630 | + } | |
| 631 | + | |
| 632 | + #[test] | |
| 633 | + fn pull_request_and_manual_events() { | |
| 634 | + let cfg = parse_config(EXAMPLE).unwrap(); | |
| 635 | + assert!(cfg.triggered_by(&Event::PullRequest)); | |
| 636 | + assert!(cfg.triggered_by(&Event::Manual)); | |
| 637 | + let cfg = parse_config(&job_toml("")).unwrap(); | |
| 638 | + assert!(!cfg.triggered_by(&Event::PullRequest)); | |
| 639 | + } | |
| 640 | + | |
| 641 | + #[test] | |
| 642 | + fn job_order_is_topological_and_stable() { | |
| 643 | + let text = "[jobs.lint]\nimage = \"i\"\nrun = \"r\"\nneeds = [\"test\"]\n\ | |
| 644 | + [jobs.test]\nimage = \"i\"\nrun = \"r\"\n\ | |
| 645 | + [jobs.docs]\nimage = \"i\"\nrun = \"r\"\n\ | |
| 646 | + [jobs.deploy]\nimage = \"i\"\nrun = \"r\"\nneeds = [\"lint\", \"docs\"]\n"; | |
| 647 | + let cfg = parse_config(text).unwrap(); | |
| 648 | + assert_eq!(cfg.job_order(), vec!["docs", "test", "lint", "deploy"]); | |
| 649 | + // Independent jobs come out in name order. | |
| 650 | + let flat = parse_config( | |
| 651 | + "[jobs.c]\nimage = \"i\"\nrun = \"r\"\n[jobs.a]\nimage = \"i\"\nrun = \"r\"\n[jobs.b]\nimage = \"i\"\nrun = \"r\"\n", | |
| 652 | + ) | |
| 653 | + .unwrap(); | |
| 654 | + assert_eq!(flat.job_order(), vec!["a", "b", "c"]); | |
| 655 | + } | |
| 656 | + | |
| 657 | + #[test] | |
| 658 | + fn example_order() { | |
| 659 | + let cfg = parse_config(EXAMPLE).unwrap(); | |
| 660 | + assert_eq!(cfg.job_order(), vec!["test", "lint"]); | |
| 661 | + } | |
| 662 | +} |
crates/forge-ci/src/error.rs| @@ -0,0 +1,60 @@ | ||
| 1 | +//! Errors returned by [`crate::config::parse_config`]. | |
| 2 | + | |
| 3 | +use thiserror::Error; | |
| 4 | + | |
| 5 | +/// Why a `.forge/ci.toml` file was rejected. | |
| 6 | +#[derive(Debug, Clone, PartialEq, Eq, Error)] | |
| 7 | +pub enum ConfigError { | |
| 8 | + #[error("ci config is {size} bytes, the limit is {max} bytes")] | |
| 9 | + TooLarge { size: usize, max: usize }, | |
| 10 | + | |
| 11 | + #[error("invalid TOML: {0}")] | |
| 12 | + Toml(String), | |
| 13 | + | |
| 14 | + #[error("no jobs are defined")] | |
| 15 | + NoJobs, | |
| 16 | + | |
| 17 | + #[error("{count} jobs are defined, the limit is {max}")] | |
| 18 | + TooManyJobs { count: usize, max: usize }, | |
| 19 | + | |
| 20 | + #[error("invalid job name {0:?}: must match ^[a-z0-9][a-z0-9_-]{{0,39}}$")] | |
| 21 | + InvalidJobName(String), | |
| 22 | + | |
| 23 | + #[error("job {job:?} needs unknown job {need:?}")] | |
| 24 | + UnknownNeed { job: String, need: String }, | |
| 25 | + | |
| 26 | + #[error("jobs form a cycle, involving job {job:?}")] | |
| 27 | + Cycle { job: String }, | |
| 28 | + | |
| 29 | + #[error("job {job:?}: image must be non-empty and contain no whitespace")] | |
| 30 | + InvalidImage { job: String }, | |
| 31 | + | |
| 32 | + #[error("job {job:?}: run must not be empty")] | |
| 33 | + EmptyRun { job: String }, | |
| 34 | + | |
| 35 | + #[error("job {job:?}: invalid secret name {name:?}: must match ^[A-Z_][A-Z0-9_]{{0,63}}$")] | |
| 36 | + InvalidSecretName { job: String, name: String }, | |
| 37 | + | |
| 38 | + #[error("job {job:?}: invalid env name {name:?}: must match ^[A-Z_][A-Z0-9_]{{0,63}}$")] | |
| 39 | + InvalidEnvName { job: String, name: String }, | |
| 40 | + | |
| 41 | + #[error("job {job:?}: invalid artifact pattern {pattern:?}: {reason}")] | |
| 42 | + InvalidArtifactGlob { | |
| 43 | + job: String, | |
| 44 | + pattern: String, | |
| 45 | + reason: String, | |
| 46 | + }, | |
| 47 | + | |
| 48 | + #[error("job {job:?}: invalid runner label {label:?}")] | |
| 49 | + InvalidRunsOn { job: String, label: String }, | |
| 50 | + | |
| 51 | + #[error("job {job:?}: timeout_minutes must be between 1 and 360, got {value}")] | |
| 52 | + InvalidTimeout { job: String, value: u32 }, | |
| 53 | + | |
| 54 | + #[error("invalid {kind} glob {pattern:?}: {reason}")] | |
| 55 | + InvalidTriggerGlob { | |
| 56 | + kind: &'static str, | |
| 57 | + pattern: String, | |
| 58 | + reason: String, | |
| 59 | + }, | |
| 60 | +} |
crates/forge-ci/src/lib.rs| @@ -0,0 +1,9 @@ | ||
| 1 | +//! CI workflow config (`.forge/ci.toml`), the runner protocol, and log masking. Shared by | |
| 2 | +//! forge-web and forge-runner. | |
| 3 | + | |
| 4 | +pub mod config; | |
| 5 | +pub mod error; | |
| 6 | +pub mod mask; | |
| 7 | +pub mod proto; | |
| 8 | + | |
| 9 | +pub use error::ConfigError; |
crates/forge-ci/src/mask.rs| @@ -0,0 +1,256 @@ | ||
| 1 | +//! Secret masking for job logs. Replaces every secret value, and its standard base64 encoding, | |
| 2 | +//! with `***`. [`MaskStream`] does the same over chunks, so a secret split across two chunks is | |
| 3 | +//! still masked. | |
| 4 | + | |
| 5 | +use std::collections::BTreeSet; | |
| 6 | +use std::fmt; | |
| 7 | + | |
| 8 | +use base64::Engine as _; | |
| 9 | +use base64::engine::general_purpose::STANDARD; | |
| 10 | + | |
| 11 | +/// The text that replaces every match. | |
| 12 | +pub const MASK: &str = "***"; | |
| 13 | +/// Values shorter than this (in chars) are not masked, since they would hide ordinary output. | |
| 14 | +pub const MIN_SECRET_CHARS: usize = 4; | |
| 15 | + | |
| 16 | +/// Masks a fixed set of secret values. | |
| 17 | +#[derive(Clone)] | |
| 18 | +pub struct Masker { | |
| 19 | + /// Byte patterns (raw values and their base64), longest first, deduplicated. | |
| 20 | + patterns: Vec<Vec<u8>>, | |
| 21 | +} | |
| 22 | + | |
| 23 | +impl fmt::Debug for Masker { | |
| 24 | + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 25 | + f.debug_struct("Masker") | |
| 26 | + .field("patterns", &self.patterns.len()) | |
| 27 | + .finish() | |
| 28 | + } | |
| 29 | +} | |
| 30 | + | |
| 31 | +impl Masker { | |
| 32 | + /// Builds a masker. Values shorter than [`MIN_SECRET_CHARS`] are ignored. | |
| 33 | + pub fn new(secrets: impl IntoIterator<Item = String>) -> Self { | |
| 34 | + let mut set: BTreeSet<Vec<u8>> = BTreeSet::new(); | |
| 35 | + for value in secrets { | |
| 36 | + if value.chars().count() < MIN_SECRET_CHARS { | |
| 37 | + continue; | |
| 38 | + } | |
| 39 | + let encoded = STANDARD.encode(value.as_bytes()); | |
| 40 | + set.insert(value.into_bytes()); | |
| 41 | + set.insert(encoded.into_bytes()); | |
| 42 | + } | |
| 43 | + let mut patterns: Vec<Vec<u8>> = set.into_iter().collect(); | |
| 44 | + patterns.sort_by_key(|p| std::cmp::Reverse(p.len())); | |
| 45 | + Self { patterns } | |
| 46 | + } | |
| 47 | + | |
| 48 | + /// Masks every occurrence in `text`. | |
| 49 | + pub fn mask(&self, text: &str) -> String { | |
| 50 | + let (out, _) = scan(&self.patterns, text.as_bytes(), true); | |
| 51 | + bytes_to_string(out) | |
| 52 | + } | |
| 53 | + | |
| 54 | + /// Starts a streaming masker that owns a copy of this masker. | |
| 55 | + pub fn stream(&self) -> MaskStream { | |
| 56 | + MaskStream::new(self.clone()) | |
| 57 | + } | |
| 58 | +} | |
| 59 | + | |
| 60 | +/// Masks a byte stream that arrives in chunks. | |
| 61 | +#[derive(Debug, Clone)] | |
| 62 | +pub struct MaskStream { | |
| 63 | + masker: Masker, | |
| 64 | + /// Bytes held back because they may be the start of a secret. Never more than the longest | |
| 65 | + /// pattern minus one. | |
| 66 | + held: Vec<u8>, | |
| 67 | +} | |
| 68 | + | |
| 69 | +impl MaskStream { | |
| 70 | + pub fn new(masker: Masker) -> Self { | |
| 71 | + Self { | |
| 72 | + masker, | |
| 73 | + held: Vec::new(), | |
| 74 | + } | |
| 75 | + } | |
| 76 | + | |
| 77 | + /// Feeds one chunk. Returns the masked bytes that are now safe to emit. | |
| 78 | + pub fn push(&mut self, chunk: &[u8]) -> Vec<u8> { | |
| 79 | + self.held.extend_from_slice(chunk); | |
| 80 | + let (out, rest) = scan(&self.masker.patterns, &self.held, false); | |
| 81 | + self.held = rest; | |
| 82 | + out | |
| 83 | + } | |
| 84 | + | |
| 85 | + /// Ends the stream and returns whatever is still held back, masked. | |
| 86 | + pub fn finish(&mut self) -> Vec<u8> { | |
| 87 | + let held = std::mem::take(&mut self.held); | |
| 88 | + let (out, _) = scan(&self.masker.patterns, &held, true); | |
| 89 | + out | |
| 90 | + } | |
| 91 | +} | |
| 92 | + | |
| 93 | +/// Walks `input` and writes masked bytes. A match is replaced by [`MASK`]. When `last` is false, | |
| 94 | +/// the scan stops at the first position where a pattern could still complete in later input, and | |
| 95 | +/// returns that unprocessed tail. When `last` is true, the whole input is processed. | |
| 96 | +fn scan(patterns: &[Vec<u8>], input: &[u8], last: bool) -> (Vec<u8>, Vec<u8>) { | |
| 97 | + let mut out = Vec::with_capacity(input.len()); | |
| 98 | + let mut i = 0; | |
| 99 | + while i < input.len() { | |
| 100 | + let tail = &input[i..]; | |
| 101 | + if let Some(p) = patterns.iter().find(|p| tail.starts_with(p)) { | |
| 102 | + out.extend_from_slice(MASK.as_bytes()); | |
| 103 | + i += p.len(); | |
| 104 | + continue; | |
| 105 | + } | |
| 106 | + if !last | |
| 107 | + && patterns | |
| 108 | + .iter() | |
| 109 | + .any(|p| p.len() > tail.len() && p.starts_with(tail)) | |
| 110 | + { | |
| 111 | + return (out, tail.to_vec()); | |
| 112 | + } | |
| 113 | + out.push(input[i]); | |
| 114 | + i += 1; | |
| 115 | + } | |
| 116 | + (out, Vec::new()) | |
| 117 | +} | |
| 118 | + | |
| 119 | +fn bytes_to_string(bytes: Vec<u8>) -> String { | |
| 120 | + match String::from_utf8(bytes) { | |
| 121 | + Ok(s) => s, | |
| 122 | + Err(e) => String::from_utf8_lossy(e.as_bytes()).into_owned(), | |
| 123 | + } | |
| 124 | +} | |
| 125 | + | |
| 126 | +#[cfg(test)] | |
| 127 | +mod tests { | |
| 128 | + use super::*; | |
| 129 | + | |
| 130 | + fn masker(values: &[&str]) -> Masker { | |
| 131 | + Masker::new(values.iter().map(|v| v.to_string())) | |
| 132 | + } | |
| 133 | + | |
| 134 | + fn stream_all(m: &Masker, chunks: &[&[u8]]) -> String { | |
| 135 | + let mut s = m.stream(); | |
| 136 | + let mut out = Vec::new(); | |
| 137 | + for c in chunks { | |
| 138 | + out.extend(s.push(c)); | |
| 139 | + } | |
| 140 | + out.extend(s.finish()); | |
| 141 | + String::from_utf8(out).unwrap() | |
| 142 | + } | |
| 143 | + | |
| 144 | + #[test] | |
| 145 | + fn masks_plain_values() { | |
| 146 | + let m = masker(&["hunter2-secret"]); | |
| 147 | + assert_eq!(m.mask("token=hunter2-secret ok"), "token=*** ok"); | |
| 148 | + assert_eq!( | |
| 149 | + m.mask("two hunter2-secret and hunter2-secret"), | |
| 150 | + "two *** and ***" | |
| 151 | + ); | |
| 152 | + assert_eq!(m.mask("nothing here"), "nothing here"); | |
| 153 | + } | |
| 154 | + | |
| 155 | + #[test] | |
| 156 | + fn masks_base64_encoding() { | |
| 157 | + let secret = "hunter2-secret"; | |
| 158 | + let encoded = STANDARD.encode(secret.as_bytes()); | |
| 159 | + let m = masker(&[secret]); | |
| 160 | + assert_eq!(m.mask(&format!("leak: {encoded}")), "leak: ***"); | |
| 161 | + let other = masker(&["abcdefgh"]); | |
| 162 | + let enc = STANDARD.encode("abcdefgh"); | |
| 163 | + assert_eq!(other.mask(&enc), "***"); | |
| 164 | + } | |
| 165 | + | |
| 166 | + #[test] | |
| 167 | + fn short_values_are_ignored() { | |
| 168 | + let m = masker(&["abc", "", "ab"]); | |
| 169 | + assert_eq!(m.mask("abc ab"), "abc ab"); | |
| 170 | + // 4 chars is the minimum that is masked. | |
| 171 | + let m = masker(&["abcd"]); | |
| 172 | + assert_eq!(m.mask("xabcdx"), "x***x"); | |
| 173 | + } | |
| 174 | + | |
| 175 | + #[test] | |
| 176 | + fn longest_match_wins() { | |
| 177 | + let m = masker(&["secret-value", "secret"]); | |
| 178 | + assert_eq!(m.mask("a secret-value b"), "a *** b"); | |
| 179 | + } | |
| 180 | + | |
| 181 | + #[test] | |
| 182 | + fn empty_secret_set_is_a_no_op() { | |
| 183 | + let m = Masker::new(std::iter::empty()); | |
| 184 | + assert_eq!(m.mask("anything"), "anything"); | |
| 185 | + assert_eq!(stream_all(&m, &[b"anything"]), "anything"); | |
| 186 | + } | |
| 187 | + | |
| 188 | + #[test] | |
| 189 | + fn secret_split_across_two_chunks() { | |
| 190 | + let m = masker(&["hunter2-secret"]); | |
| 191 | + let out = stream_all(&m, &[b"pre hunter2-", b"secret post"]); | |
| 192 | + assert_eq!(out, "pre *** post"); | |
| 193 | + } | |
| 194 | + | |
| 195 | + #[test] | |
| 196 | + fn secret_split_one_byte_at_a_time() { | |
| 197 | + let m = masker(&["hunter2-secret"]); | |
| 198 | + let text = b"a hunter2-secret b hunter2-secret c"; | |
| 199 | + let chunks: Vec<&[u8]> = text.chunks(1).collect(); | |
| 200 | + assert_eq!(stream_all(&m, &chunks), "a *** b *** c"); | |
| 201 | + } | |
| 202 | + | |
| 203 | + #[test] | |
| 204 | + fn base64_split_across_chunks() { | |
| 205 | + let secret = "hunter2-secret"; | |
| 206 | + let encoded = STANDARD.encode(secret); | |
| 207 | + let m = masker(&[secret]); | |
| 208 | + let (a, b) = encoded.split_at(5); | |
| 209 | + let out = stream_all( | |
| 210 | + &m, | |
| 211 | + &[format!("x{a}").as_bytes(), format!("{b}y").as_bytes()], | |
| 212 | + ); | |
| 213 | + assert_eq!(out, "x***y"); | |
| 214 | + } | |
| 215 | + | |
| 216 | + #[test] | |
| 217 | + fn held_bytes_are_bounded() { | |
| 218 | + let m = masker(&["0123456789"]); | |
| 219 | + let mut s = m.stream(); | |
| 220 | + let out = s.push(b"0123456"); | |
| 221 | + assert!(out.is_empty()); | |
| 222 | + assert!(s.held.len() <= 9); | |
| 223 | + let out = s.push(b"x"); | |
| 224 | + assert_eq!(out, b"0123456x".to_vec()); | |
| 225 | + } | |
| 226 | + | |
| 227 | + #[test] | |
| 228 | + fn partial_prefix_that_does_not_complete_is_emitted() { | |
| 229 | + let m = masker(&["hunter2-secret"]); | |
| 230 | + let out = stream_all(&m, &[b"hunt", b"er is here"]); | |
| 231 | + assert_eq!(out, "hunter is here"); | |
| 232 | + } | |
| 233 | + | |
| 234 | + #[test] | |
| 235 | + fn finish_flushes_held_partial_match() { | |
| 236 | + let m = masker(&["hunter2-secret"]); | |
| 237 | + let mut s = m.stream(); | |
| 238 | + let mut out = s.push(b"end hunter2-se"); | |
| 239 | + out.extend(s.finish()); | |
| 240 | + assert_eq!(String::from_utf8(out).unwrap(), "end hunter2-se"); | |
| 241 | + } | |
| 242 | + | |
| 243 | + #[test] | |
| 244 | + fn multibyte_text_survives() { | |
| 245 | + let m = masker(&["hunter2-secret"]); | |
| 246 | + assert_eq!(m.mask("héllo 🙂 hunter2-secret ✓"), "héllo 🙂 *** ✓"); | |
| 247 | + let out = stream_all(&m, &["héllo 🙂 ".as_bytes(), "hunter2-secret ✓".as_bytes()]); | |
| 248 | + assert_eq!(out, "héllo 🙂 *** ✓"); | |
| 249 | + } | |
| 250 | + | |
| 251 | + #[test] | |
| 252 | + fn debug_does_not_print_values() { | |
| 253 | + let m = masker(&["hunter2-secret"]); | |
| 254 | + assert!(!format!("{m:?}").contains("hunter2")); | |
| 255 | + } | |
| 256 | +} |
crates/forge-ci/src/proto.rs| @@ -0,0 +1,173 @@ | ||
| 1 | +//! Runner <-> forge JSON protocol. All types use snake_case field names and are serialized as JSON. | |
| 2 | +//! | |
| 3 | +//! Types that carry secrets (`RegisterRequest`, `RegisterResponse`, `JobAssignment`) implement | |
| 4 | +//! `Debug` by hand and print `<redacted>` for their secret fields, so they never end up in logs. | |
| 5 | + | |
| 6 | +use std::collections::BTreeMap; | |
| 7 | +use std::fmt; | |
| 8 | + | |
| 9 | +use serde::{Deserialize, Serialize}; | |
| 10 | + | |
| 11 | +/// `POST /api/v1/ci/runners/register` | |
| 12 | +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] | |
| 13 | +pub struct RegisterRequest { | |
| 14 | + /// Single-use registration token, as shown once by the admin page. | |
| 15 | + pub token: String, | |
| 16 | + pub name: String, | |
| 17 | + pub labels: Vec<String>, | |
| 18 | +} | |
| 19 | + | |
| 20 | +impl fmt::Debug for RegisterRequest { | |
| 21 | + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 22 | + f.debug_struct("RegisterRequest") | |
| 23 | + .field("token", &"<redacted>") | |
| 24 | + .field("name", &self.name) | |
| 25 | + .field("labels", &self.labels) | |
| 26 | + .finish() | |
| 27 | + } | |
| 28 | +} | |
| 29 | + | |
| 30 | +/// Response to [`RegisterRequest`]. | |
| 31 | +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] | |
| 32 | +pub struct RegisterResponse { | |
| 33 | + pub runner_id: String, | |
| 34 | + /// Bearer secret for all later runner calls. Shown to the runner once. | |
| 35 | + pub runner_secret: String, | |
| 36 | +} | |
| 37 | + | |
| 38 | +impl fmt::Debug for RegisterResponse { | |
| 39 | + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 40 | + f.debug_struct("RegisterResponse") | |
| 41 | + .field("runner_id", &self.runner_id) | |
| 42 | + .field("runner_secret", &"<redacted>") | |
| 43 | + .finish() | |
| 44 | + } | |
| 45 | +} | |
| 46 | + | |
| 47 | +/// `POST /api/v1/ci/runners/claim`. Long-polls for up to 30s; 204 means nothing was claimed. | |
| 48 | +/// The labels are informational: the server uses the labels stored for the runner. | |
| 49 | +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 50 | +pub struct ClaimRequest { | |
| 51 | + pub labels: Vec<String>, | |
| 52 | +} | |
| 53 | + | |
| 54 | +/// Returned with 200 by the claim endpoint. | |
| 55 | +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] | |
| 56 | +pub struct JobAssignment { | |
| 57 | + pub job_id: String, | |
| 58 | + /// Bearer token for `/api/v1/ci/jobs/{id}/...` and for read-only git access. | |
| 59 | + pub job_token: String, | |
| 60 | + /// `owner/name`. | |
| 61 | + pub repo: String, | |
| 62 | + /// HTTPS clone URL of the repo. | |
| 63 | + pub clone_url: String, | |
| 64 | + pub commit: String, | |
| 65 | + pub git_ref: String, | |
| 66 | + pub job_name: String, | |
| 67 | + pub image: String, | |
| 68 | + pub run: String, | |
| 69 | + pub env: BTreeMap<String, String>, | |
| 70 | + /// Secret values, by name. Never print these. | |
| 71 | + pub secrets: BTreeMap<String, String>, | |
| 72 | + pub artifacts: Vec<String>, | |
| 73 | + pub timeout_minutes: u32, | |
| 74 | + pub network: bool, | |
| 75 | +} | |
| 76 | + | |
| 77 | +impl fmt::Debug for JobAssignment { | |
| 78 | + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 79 | + f.debug_struct("JobAssignment") | |
| 80 | + .field("job_id", &self.job_id) | |
| 81 | + .field("job_token", &"<redacted>") | |
| 82 | + .field("repo", &self.repo) | |
| 83 | + .field("clone_url", &self.clone_url) | |
| 84 | + .field("commit", &self.commit) | |
| 85 | + .field("git_ref", &self.git_ref) | |
| 86 | + .field("job_name", &self.job_name) | |
| 87 | + .field("image", &self.image) | |
| 88 | + .field("run", &self.run) | |
| 89 | + .field("env", &self.env) | |
| 90 | + .field("secrets", &self.secrets.keys().collect::<Vec<_>>()) | |
| 91 | + .field("artifacts", &self.artifacts) | |
| 92 | + .field("timeout_minutes", &self.timeout_minutes) | |
| 93 | + .field("network", &self.network) | |
| 94 | + .finish() | |
| 95 | + } | |
| 96 | +} | |
| 97 | + | |
| 98 | +/// Response to `POST /api/v1/ci/jobs/{id}/heartbeat`. | |
| 99 | +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 100 | +pub struct HeartbeatResponse { | |
| 101 | + pub cancel: bool, | |
| 102 | +} | |
| 103 | + | |
| 104 | +/// `POST /api/v1/ci/jobs/{id}/finish`. | |
| 105 | +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 106 | +pub struct FinishRequest { | |
| 107 | + pub outcome: Outcome, | |
| 108 | + pub exit_code: Option<i32>, | |
| 109 | + pub message: Option<String>, | |
| 110 | +} | |
| 111 | + | |
| 112 | +/// Final result of a job, reported by the runner. | |
| 113 | +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 114 | +#[serde(rename_all = "snake_case")] | |
| 115 | +pub enum Outcome { | |
| 116 | + Success, | |
| 117 | + Failure, | |
| 118 | + Cancelled, | |
| 119 | + Error, | |
| 120 | +} | |
| 121 | + | |
| 122 | +#[cfg(test)] | |
| 123 | +mod tests { | |
| 124 | + use super::*; | |
| 125 | + | |
| 126 | + #[test] | |
| 127 | + fn outcome_is_snake_case() { | |
| 128 | + let json = serde_json::to_string(&Outcome::Cancelled).unwrap(); | |
| 129 | + assert_eq!(json, "\"cancelled\""); | |
| 130 | + } | |
| 131 | + | |
| 132 | + #[test] | |
| 133 | + fn finish_request_round_trips() { | |
| 134 | + let req = FinishRequest { | |
| 135 | + outcome: Outcome::Failure, | |
| 136 | + exit_code: Some(2), | |
| 137 | + message: None, | |
| 138 | + }; | |
| 139 | + let json = serde_json::to_string(&req).unwrap(); | |
| 140 | + assert_eq!( | |
| 141 | + json, | |
| 142 | + r#"{"outcome":"failure","exit_code":2,"message":null}"# | |
| 143 | + ); | |
| 144 | + let back: FinishRequest = serde_json::from_str(&json).unwrap(); | |
| 145 | + assert_eq!(back, req); | |
| 146 | + } | |
| 147 | + | |
| 148 | + #[test] | |
| 149 | + fn debug_redacts_secrets() { | |
| 150 | + let mut secrets = BTreeMap::new(); | |
| 151 | + secrets.insert("DEPLOY_KEY".to_string(), "hunter2-value".to_string()); | |
| 152 | + let job = JobAssignment { | |
| 153 | + job_id: "j1".into(), | |
| 154 | + job_token: "tok-secret".into(), | |
| 155 | + repo: "o/r".into(), | |
| 156 | + clone_url: "https://example.test/o/r.git".into(), | |
| 157 | + commit: "abc".into(), | |
| 158 | + git_ref: "refs/heads/main".into(), | |
| 159 | + job_name: "lint".into(), | |
| 160 | + image: "docker.io/rust:1".into(), | |
| 161 | + run: "true".into(), | |
| 162 | + env: BTreeMap::new(), | |
| 163 | + secrets, | |
| 164 | + artifacts: vec![], | |
| 165 | + timeout_minutes: 60, | |
| 166 | + network: true, | |
| 167 | + }; | |
| 168 | + let dbg = format!("{job:?}"); | |
| 169 | + assert!(!dbg.contains("hunter2-value")); | |
| 170 | + assert!(!dbg.contains("tok-secret")); | |
| 171 | + assert!(dbg.contains("DEPLOY_KEY")); | |
| 172 | + } | |
| 173 | +} |
crates/forge-core/Cargo.toml| @@ -0,0 +1,36 @@ | ||
| 1 | +[package] | |
| 2 | +name = "forge-core" | |
| 3 | +description = "Domain types, storage, config and security primitives for forge" | |
| 4 | +version.workspace = true | |
| 5 | +edition.workspace = true | |
| 6 | +license.workspace = true | |
| 7 | +publish = false | |
| 8 | + | |
| 9 | +[dependencies] | |
| 10 | +thiserror.workspace = true | |
| 11 | +ipnet.workspace = true | |
| 12 | +serde.workspace = true | |
| 13 | +toml.workspace = true | |
| 14 | +ulid.workspace = true | |
| 15 | +time.workspace = true | |
| 16 | +unicode-normalization.workspace = true | |
| 17 | +jiff = { workspace = true } | |
| 18 | +argon2.workspace = true | |
| 19 | +rand.workspace = true | |
| 20 | +sha2.workspace = true | |
| 21 | +hex.workspace = true | |
| 22 | +hmac = { workspace = true } | |
| 23 | +sha1.workspace = true | |
| 24 | +image = { workspace = true } | |
| 25 | +subtle.workspace = true | |
| 26 | +base64.workspace = true | |
| 27 | +chacha20poly1305.workspace = true | |
| 28 | +turso.workspace = true | |
| 29 | +tokio = { workspace = true, features = ["sync"] } | |
| 30 | +tracing.workspace = true | |
| 31 | +serde_json.workspace = true | |
| 32 | +semver = { workspace = true } | |
| 33 | +zip = { workspace = true } | |
| 34 | + | |
| 35 | +[dev-dependencies] | |
| 36 | +tempfile.workspace = true |
crates/forge-core/migrations/0001_init.sql| @@ -0,0 +1,92 @@ | ||
| 1 | +-- Milestone 1 schema. Applied by store/migrate.rs, which records the version in | |
| 2 | +-- schema_migrations. Timestamps are RFC 3339 UTC strings (see time::now_stamp). | |
| 3 | + | |
| 4 | +CREATE TABLE users ( | |
| 5 | + id TEXT PRIMARY KEY, | |
| 6 | + username TEXT NOT NULL, | |
| 7 | + username_folded TEXT NOT NULL UNIQUE, | |
| 8 | + email TEXT, | |
| 9 | + password_hash TEXT NOT NULL, | |
| 10 | + is_admin INTEGER NOT NULL DEFAULT 0, | |
| 11 | + created_at TEXT NOT NULL | |
| 12 | +); | |
| 13 | + | |
| 14 | +CREATE TABLE sessions ( | |
| 15 | + id TEXT PRIMARY KEY, | |
| 16 | + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 17 | + token_hash TEXT NOT NULL UNIQUE, | |
| 18 | + csrf_token TEXT NOT NULL, | |
| 19 | + created_at TEXT NOT NULL, | |
| 20 | + expires_at TEXT NOT NULL, | |
| 21 | + last_seen TEXT NOT NULL, | |
| 22 | + user_agent TEXT | |
| 23 | +); | |
| 24 | +CREATE INDEX sessions_user_id ON sessions(user_id); | |
| 25 | +CREATE INDEX sessions_expires_at ON sessions(expires_at); | |
| 26 | + | |
| 27 | +CREATE TABLE ssh_keys ( | |
| 28 | + id TEXT PRIMARY KEY, | |
| 29 | + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 30 | + title TEXT NOT NULL, | |
| 31 | + algorithm TEXT NOT NULL, | |
| 32 | + key_blob BLOB NOT NULL UNIQUE, | |
| 33 | + fingerprint TEXT NOT NULL, | |
| 34 | + created_at TEXT NOT NULL, | |
| 35 | + last_used_at TEXT | |
| 36 | +); | |
| 37 | +CREATE INDEX ssh_keys_user_id ON ssh_keys(user_id); | |
| 38 | + | |
| 39 | +CREATE TABLE access_tokens ( | |
| 40 | + id TEXT PRIMARY KEY, | |
| 41 | + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 42 | + name TEXT NOT NULL, | |
| 43 | + token_hash TEXT NOT NULL UNIQUE, | |
| 44 | + scopes TEXT NOT NULL DEFAULT '[]', | |
| 45 | + expires_at TEXT, | |
| 46 | + created_at TEXT NOT NULL, | |
| 47 | + last_used_at TEXT | |
| 48 | +); | |
| 49 | +CREATE INDEX access_tokens_user_id ON access_tokens(user_id); | |
| 50 | + | |
| 51 | +CREATE TABLE repos ( | |
| 52 | + id TEXT PRIMARY KEY, | |
| 53 | + owner_id TEXT NOT NULL REFERENCES users(id), | |
| 54 | + name TEXT NOT NULL, | |
| 55 | + name_folded TEXT NOT NULL, | |
| 56 | + description TEXT NOT NULL DEFAULT '', | |
| 57 | + visibility TEXT NOT NULL, | |
| 58 | + default_branch TEXT NOT NULL, | |
| 59 | + archived INTEGER NOT NULL DEFAULT 0, | |
| 60 | + created_at TEXT NOT NULL, | |
| 61 | + UNIQUE(owner_id, name_folded) | |
| 62 | +); | |
| 63 | + | |
| 64 | +-- Old owner/name pairs (folded) that now point at a repo, e.g. after a rename. | |
| 65 | +CREATE TABLE repo_redirects ( | |
| 66 | + owner_folded TEXT NOT NULL, | |
| 67 | + name_folded TEXT NOT NULL, | |
| 68 | + repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | |
| 69 | + created_at TEXT NOT NULL, | |
| 70 | + PRIMARY KEY (owner_folded, name_folded) | |
| 71 | +); | |
| 72 | +CREATE INDEX repo_redirects_repo_id ON repo_redirects(repo_id); | |
| 73 | + | |
| 74 | +CREATE TABLE collaborators ( | |
| 75 | + repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | |
| 76 | + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 77 | + role TEXT NOT NULL, | |
| 78 | + PRIMARY KEY (repo_id, user_id) | |
| 79 | +); | |
| 80 | +CREATE INDEX collaborators_user_id ON collaborators(user_id); | |
| 81 | + | |
| 82 | +CREATE TABLE push_events ( | |
| 83 | + id TEXT PRIMARY KEY, | |
| 84 | + repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | |
| 85 | + actor_id TEXT NOT NULL REFERENCES users(id), | |
| 86 | + ref_name TEXT NOT NULL, | |
| 87 | + old_oid TEXT NOT NULL, | |
| 88 | + new_oid TEXT NOT NULL, | |
| 89 | + protocol TEXT NOT NULL, | |
| 90 | + created_at TEXT NOT NULL | |
| 91 | +); | |
| 92 | +CREATE INDEX push_events_repo_created ON push_events(repo_id, created_at); |
crates/forge-core/migrations/0002_profile.sql| @@ -0,0 +1,12 @@ | ||
| 1 | +-- Profile: about text, IANA timezone, and avatars stored in the database. Applied by | |
| 2 | +-- store/migrate.rs. Existing users get about = '' and timezone = 'UTC'. | |
| 3 | + | |
| 4 | +ALTER TABLE users ADD COLUMN about TEXT NOT NULL DEFAULT ''; | |
| 5 | +ALTER TABLE users ADD COLUMN timezone TEXT NOT NULL DEFAULT 'UTC'; | |
| 6 | + | |
| 7 | +CREATE TABLE avatars ( | |
| 8 | + user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, | |
| 9 | + data BLOB NOT NULL, -- re-encoded PNG, 256x256 | |
| 10 | + hash TEXT NOT NULL, -- hex sha256 of data (first 16 chars), used as ETag and as ?v= | |
| 11 | + updated_at TEXT NOT NULL | |
| 12 | +); |
crates/forge-core/migrations/0003_branch_protection.sql| @@ -0,0 +1,12 @@ | ||
| 1 | +-- Branch protection rules per repo. Applied by store/migrate.rs. Patterns are literal branch | |
| 2 | +-- names or globs (`*` within one path segment, `**` across segments); see protection.rs. | |
| 3 | + | |
| 4 | +CREATE TABLE branch_protections ( | |
| 5 | + id TEXT PRIMARY KEY, -- ULID | |
| 6 | + repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | |
| 7 | + pattern TEXT NOT NULL, -- "main", "release/*", "**" | |
| 8 | + allow_force_push INTEGER NOT NULL DEFAULT 0, | |
| 9 | + allow_deletion INTEGER NOT NULL DEFAULT 0, | |
| 10 | + created_at TEXT NOT NULL, | |
| 11 | + UNIQUE (repo_id, pattern) | |
| 12 | +); |
crates/forge-core/migrations/0004_issues.sql| @@ -0,0 +1,61 @@ | ||
| 1 | +-- Issues, comments, labels and milestones (milestone 3). Applied by store/migrate.rs. | |
| 2 | +-- Numbers come from repos.next_issue_number, allocated in the same transaction as the insert. | |
| 3 | + | |
| 4 | +ALTER TABLE repos ADD COLUMN next_issue_number INTEGER NOT NULL DEFAULT 1; | |
| 5 | + | |
| 6 | +CREATE TABLE milestones ( | |
| 7 | + id TEXT PRIMARY KEY, repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | |
| 8 | + title TEXT NOT NULL, title_folded TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', | |
| 9 | + due_on TEXT, -- 'YYYY-MM-DD' or NULL | |
| 10 | + state TEXT NOT NULL DEFAULT 'open',-- 'open' | 'closed' | |
| 11 | + created_at TEXT NOT NULL, closed_at TEXT, | |
| 12 | + UNIQUE (repo_id, title_folded)); | |
| 13 | + | |
| 14 | +CREATE TABLE issues ( | |
| 15 | + id TEXT PRIMARY KEY, repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | |
| 16 | + number INTEGER NOT NULL, | |
| 17 | + kind TEXT NOT NULL DEFAULT 'issue', -- 'issue' now; 'pull' in M4 (shared numbering) | |
| 18 | + title TEXT NOT NULL, body TEXT NOT NULL DEFAULT '', | |
| 19 | + state TEXT NOT NULL DEFAULT 'open', -- 'open' | 'closed' | |
| 20 | + state_reason TEXT, -- NULL | 'completed' | 'not_planned' | |
| 21 | + author_id TEXT NOT NULL REFERENCES users(id), | |
| 22 | + milestone_id TEXT REFERENCES milestones(id) ON DELETE SET NULL, | |
| 23 | + comment_count INTEGER NOT NULL DEFAULT 0, | |
| 24 | + created_at TEXT NOT NULL, updated_at TEXT NOT NULL, closed_at TEXT, | |
| 25 | + UNIQUE (repo_id, number)); | |
| 26 | +CREATE INDEX issues_repo_state ON issues(repo_id, kind, state, number); | |
| 27 | + | |
| 28 | +CREATE TABLE comments ( | |
| 29 | + id TEXT PRIMARY KEY, issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE, | |
| 30 | + author_id TEXT NOT NULL REFERENCES users(id), | |
| 31 | + body TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, | |
| 32 | + edited INTEGER NOT NULL DEFAULT 0); | |
| 33 | +CREATE INDEX comments_issue ON comments(issue_id, created_at); | |
| 34 | + | |
| 35 | +-- Edit history: the previous version is saved on every edit. | |
| 36 | +CREATE TABLE edit_history ( | |
| 37 | + id TEXT PRIMARY KEY, target_kind TEXT NOT NULL, -- 'issue' | 'comment' | |
| 38 | + target_id TEXT NOT NULL, old_title TEXT, old_body TEXT NOT NULL, | |
| 39 | + editor_id TEXT NOT NULL REFERENCES users(id), edited_at TEXT NOT NULL); | |
| 40 | +CREATE INDEX edit_history_target ON edit_history(target_kind, target_id, edited_at); | |
| 41 | + | |
| 42 | +-- Timeline events (everything except comments). | |
| 43 | +CREATE TABLE issue_events ( | |
| 44 | + id TEXT PRIMARY KEY, issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE, | |
| 45 | + actor_id TEXT REFERENCES users(id), -- NULL = system | |
| 46 | + kind TEXT NOT NULL, -- closed reopened labeled unlabeled assigned unassigned milestoned demilestoned renamed referenced | |
| 47 | + data TEXT NOT NULL DEFAULT '{}', -- JSON, e.g. {"label":"bug"} {"from":"a","to":"b"} {"commit":"<oid>"} {"reason":"completed"} | |
| 48 | + created_at TEXT NOT NULL); | |
| 49 | +CREATE INDEX issue_events_issue ON issue_events(issue_id, created_at); | |
| 50 | + | |
| 51 | +CREATE TABLE labels ( | |
| 52 | + id TEXT PRIMARY KEY, repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | |
| 53 | + name TEXT NOT NULL, name_folded TEXT NOT NULL, | |
| 54 | + color TEXT NOT NULL, -- '#rrggbb' lowercase | |
| 55 | + description TEXT NOT NULL DEFAULT '', | |
| 56 | + UNIQUE (repo_id, name_folded)); | |
| 57 | + | |
| 58 | +CREATE TABLE issue_labels (issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE, | |
| 59 | + label_id TEXT NOT NULL REFERENCES labels(id) ON DELETE CASCADE, PRIMARY KEY (issue_id, label_id)); | |
| 60 | +CREATE TABLE issue_assignees (issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE, | |
| 61 | + user_id TEXT NOT NULL REFERENCES users(id), PRIMARY KEY (issue_id, user_id)); |
crates/forge-core/migrations/0005_stars.sql| @@ -0,0 +1,9 @@ | ||
| 1 | +-- Stars: a user's bookmark on a repo. A row goes with either the user or the repo. | |
| 2 | +-- The primary key serves "repos starred by a user"; the index serves the per-repo count. | |
| 3 | + | |
| 4 | +CREATE TABLE stars ( | |
| 5 | + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 6 | + repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | |
| 7 | + created_at TEXT NOT NULL, | |
| 8 | + PRIMARY KEY (user_id, repo_id)); | |
| 9 | +CREATE INDEX stars_repo_id ON stars(repo_id); |
crates/forge-core/migrations/0006_totp.sql| @@ -0,0 +1,15 @@ | ||
| 1 | +-- Two-factor authentication (TOTP) and single-use recovery codes. Applied by store/migrate.rs. | |
| 2 | +-- totp_secret_sealed holds the instance-key sealed secret (secret::seal output, UTF-8 bytes). | |
| 3 | +-- totp_enabled stays 0 until the user confirms a code. totp_last_step is the replay guard. | |
| 4 | + | |
| 5 | +ALTER TABLE users ADD COLUMN totp_secret_sealed BLOB NULL; | |
| 6 | +ALTER TABLE users ADD COLUMN totp_enabled INTEGER NOT NULL DEFAULT 0; | |
| 7 | +ALTER TABLE users ADD COLUMN totp_last_step INTEGER NOT NULL DEFAULT 0; | |
| 8 | + | |
| 9 | +CREATE TABLE recovery_codes ( | |
| 10 | + id TEXT PRIMARY KEY, | |
| 11 | + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 12 | + code_hash TEXT NOT NULL, -- hex sha256 of the normalized code | |
| 13 | + used_at TEXT NULL | |
| 14 | +); | |
| 15 | +CREATE INDEX recovery_codes_user_id ON recovery_codes(user_id); |
crates/forge-core/migrations/0007_pulls.sql| @@ -0,0 +1,21 @@ | ||
| 1 | +-- Forks and pull requests. Applied by store/migrate.rs. | |
| 2 | +-- A pull request is an issues row with kind = 'pull' (shared numbering); this table holds the | |
| 3 | +-- branch, head and mergeability state. head_repo_id is NULL once the head repo is deleted. | |
| 4 | + | |
| 5 | +ALTER TABLE repos ADD COLUMN fork_of TEXT REFERENCES repos(id); | |
| 6 | + | |
| 7 | +CREATE TABLE pull_requests ( | |
| 8 | + issue_id TEXT PRIMARY KEY REFERENCES issues(id) ON DELETE CASCADE, | |
| 9 | + base_branch TEXT NOT NULL, | |
| 10 | + head_repo_id TEXT REFERENCES repos(id), -- NULL if the head repo was deleted | |
| 11 | + head_branch TEXT NOT NULL, | |
| 12 | + head_oid TEXT NOT NULL, -- last known head commit (also refs/pull/N/head) | |
| 13 | + base_oid TEXT NOT NULL, -- base branch tip when mergeability was last computed | |
| 14 | + merge_base TEXT, | |
| 15 | + mergeable TEXT NOT NULL DEFAULT 'unknown', -- 'unknown' | 'clean' | 'conflict' | 'merged' | 'up_to_date' | |
| 16 | + conflict_files TEXT NOT NULL DEFAULT '[]', -- JSON array of paths | |
| 17 | + merged_at TEXT, | |
| 18 | + merged_by TEXT REFERENCES users(id), | |
| 19 | + merge_commit TEXT, | |
| 20 | + allow_maintainer_edits INTEGER NOT NULL DEFAULT 1); | |
| 21 | +CREATE INDEX pull_requests_head ON pull_requests(head_repo_id, head_branch); |
crates/forge-core/migrations/0008_reviews.sql| @@ -0,0 +1,24 @@ | ||
| 1 | +-- Pull request reviews and line comments. Applied by store/migrate.rs. | |
| 2 | +-- A review belongs to a pull request (an issues row with kind = 'pull'). Review comments belong | |
| 3 | +-- to a review; a comment with reply_to set is a reply in the root comment's thread. | |
| 4 | + | |
| 5 | +CREATE TABLE pull_reviews ( | |
| 6 | + id TEXT PRIMARY KEY, issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE, | |
| 7 | + author_id TEXT NOT NULL REFERENCES users(id), | |
| 8 | + state TEXT NOT NULL, -- pending | commented | approved | changes_requested | dismissed | |
| 9 | + body TEXT NOT NULL DEFAULT '', | |
| 10 | + commit_oid TEXT NOT NULL, -- head at submit (or at creation while pending) | |
| 11 | + dismissed_reason TEXT, | |
| 12 | + created_at TEXT NOT NULL, submitted_at TEXT); | |
| 13 | +CREATE INDEX pull_reviews_issue ON pull_reviews(issue_id, submitted_at); | |
| 14 | +CREATE TABLE review_comments ( | |
| 15 | + id TEXT PRIMARY KEY, issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE, | |
| 16 | + review_id TEXT NOT NULL REFERENCES pull_reviews(id) ON DELETE CASCADE, | |
| 17 | + author_id TEXT NOT NULL REFERENCES users(id), | |
| 18 | + path TEXT NOT NULL, side TEXT NOT NULL, line INTEGER NOT NULL, | |
| 19 | + commit_oid TEXT NOT NULL, diff_hunk TEXT NOT NULL DEFAULT '', | |
| 20 | + reply_to TEXT REFERENCES review_comments(id) ON DELETE CASCADE, | |
| 21 | + body TEXT NOT NULL, outdated INTEGER NOT NULL DEFAULT 0, resolved INTEGER NOT NULL DEFAULT 0, | |
| 22 | + resolved_by TEXT REFERENCES users(id), | |
| 23 | + created_at TEXT NOT NULL, updated_at TEXT NOT NULL); | |
| 24 | +CREATE INDEX review_comments_issue ON review_comments(issue_id, path, created_at); |
crates/forge-core/migrations/0009_merge_rules.sql| @@ -0,0 +1,9 @@ | ||
| 1 | +-- Merge and push rules on branch protections. Applied by store/migrate.rs. See protection.rs. | |
| 2 | +-- required_approvals counts approvals from Write-or-higher reviewers on the base repo. | |
| 3 | +-- require_up_to_date: the pull request head must contain the base tip. | |
| 4 | +-- restrict_push: direct pushes to the branch are rejected for everyone but repo admins. | |
| 5 | + | |
| 6 | +ALTER TABLE branch_protections ADD COLUMN required_approvals INTEGER NOT NULL DEFAULT 0; | |
| 7 | +ALTER TABLE branch_protections ADD COLUMN block_on_changes_requested INTEGER NOT NULL DEFAULT 0; | |
| 8 | +ALTER TABLE branch_protections ADD COLUMN require_up_to_date INTEGER NOT NULL DEFAULT 0; | |
| 9 | +ALTER TABLE branch_protections ADD COLUMN restrict_push INTEGER NOT NULL DEFAULT 0; |
crates/forge-core/migrations/0010_audit_log.sql| @@ -0,0 +1,21 @@ | ||
| 1 | +-- Security audit log. Applied by store/migrate.rs. Append-only: the store never updates or | |
| 2 | +-- deletes rows except the retention purge (`purge_audit_before`). | |
| 3 | +-- `at` is an RFC 3339 UTC stamp with fixed width, so text order is time order. | |
| 4 | +-- actor_id is NULL for anonymous or CLI actors; actor_name keeps the name at the time. | |
| 5 | +-- target_* describe the object acted on (kind e.g. 'user', 'repo', 'token', 'ssh_key'). | |
| 6 | + | |
| 7 | +CREATE TABLE audit_log ( | |
| 8 | + id TEXT PRIMARY KEY, | |
| 9 | + at TEXT NOT NULL, | |
| 10 | + actor_id TEXT NULL, | |
| 11 | + actor_name TEXT NOT NULL DEFAULT '', | |
| 12 | + action TEXT NOT NULL, | |
| 13 | + target_kind TEXT NOT NULL DEFAULT '', | |
| 14 | + target_id TEXT NOT NULL DEFAULT '', | |
| 15 | + target_name TEXT NOT NULL DEFAULT '', | |
| 16 | + ip TEXT NOT NULL DEFAULT '', | |
| 17 | + data TEXT NOT NULL DEFAULT '{}' | |
| 18 | +); | |
| 19 | +CREATE INDEX audit_log_at ON audit_log(at); | |
| 20 | +CREATE INDEX audit_log_actor ON audit_log(actor_id, at); | |
| 21 | +CREATE INDEX audit_log_target ON audit_log(target_kind, target_id, at); |
crates/forge-core/migrations/0011_webhooks.sql| @@ -0,0 +1,44 @@ | ||
| 1 | +-- Repo webhooks and their deliveries. Applied by store/migrate.rs. | |
| 2 | +-- secret_sealed holds the secret::seal output (UTF-8 "v1:..." text stored as a BLOB, like | |
| 3 | +-- users.totp_secret_sealed). The plaintext secret never reaches the database. | |
| 4 | +-- events is a JSON array of WebhookEvent names. active is 0 or 1. | |
| 5 | +-- Timestamps are RFC 3339 UTC stamps from time.rs, so text comparison is time order. | |
| 6 | + | |
| 7 | +CREATE TABLE webhooks ( | |
| 8 | + id TEXT PRIMARY KEY, | |
| 9 | + repo_id TEXT NOT NULL REFERENCES repos(id), | |
| 10 | + url TEXT NOT NULL, | |
| 11 | + secret_sealed BLOB NOT NULL, | |
| 12 | + events TEXT NOT NULL, | |
| 13 | + active INTEGER NOT NULL DEFAULT 1, | |
| 14 | + created_by TEXT NULL REFERENCES users(id) ON DELETE SET NULL, | |
| 15 | + created_at TEXT NOT NULL, | |
| 16 | + updated_at TEXT NOT NULL | |
| 17 | +); | |
| 18 | +CREATE INDEX webhooks_repo_id ON webhooks(repo_id); | |
| 19 | + | |
| 20 | +-- state: pending (queued, not yet attempted), ok (2xx received), failed (an attempt failed and a | |
| 21 | +-- retry is scheduled at next_attempt_at), gave_up (no further attempts). | |
| 22 | +-- Claimable rows are state IN ('pending', 'failed') with next_attempt_at <= now. | |
| 23 | +-- guid is the X-Forge-Delivery value. The request columns are immutable once written. | |
| 24 | +CREATE TABLE webhook_deliveries ( | |
| 25 | + id TEXT PRIMARY KEY, | |
| 26 | + hook_id TEXT NOT NULL REFERENCES webhooks(id), | |
| 27 | + guid TEXT NOT NULL UNIQUE, | |
| 28 | + event TEXT NOT NULL, | |
| 29 | + action TEXT NOT NULL DEFAULT '', | |
| 30 | + request_headers TEXT NOT NULL, | |
| 31 | + request_body TEXT NOT NULL, | |
| 32 | + state TEXT NOT NULL CHECK (state IN ('pending', 'ok', 'failed', 'gave_up')), | |
| 33 | + attempt INTEGER NOT NULL DEFAULT 0, | |
| 34 | + next_attempt_at TEXT NULL, | |
| 35 | + response_status INTEGER NULL, | |
| 36 | + response_headers TEXT NULL, | |
| 37 | + response_body TEXT NULL, -- first 64 KiB of the body | |
| 38 | + error TEXT NULL, | |
| 39 | + duration_ms INTEGER NULL, | |
| 40 | + created_at TEXT NOT NULL, | |
| 41 | + delivered_at TEXT NULL | |
| 42 | +); | |
| 43 | +CREATE INDEX webhook_deliveries_state_next ON webhook_deliveries(state, next_attempt_at); | |
| 44 | +CREATE INDEX webhook_deliveries_hook_created ON webhook_deliveries(hook_id, created_at); |
crates/forge-core/migrations/0012_notifications.sql| @@ -0,0 +1,12 @@ | ||
| 1 | +-- Notifications: repo watch levels, issue subscriptions and per-user inbox rows. | |
| 2 | +-- Applied by store/migrate.rs. Timestamps are RFC 3339 UTC stamps from time.rs. | |
| 3 | + | |
| 4 | +CREATE TABLE repo_watches (user_id TEXT NOT NULL REFERENCES users(id), repo_id TEXT NOT NULL REFERENCES repos(id), | |
| 5 | + level TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY (user_id, repo_id)); | |
| 6 | +CREATE TABLE issue_subscriptions (user_id TEXT NOT NULL REFERENCES users(id), issue_id TEXT NOT NULL REFERENCES issues(id), | |
| 7 | + state TEXT NOT NULL, reason TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY (user_id, issue_id)); | |
| 8 | +CREATE TABLE notifications (id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id), | |
| 9 | + repo_id TEXT NOT NULL REFERENCES repos(id), issue_id TEXT NOT NULL REFERENCES issues(id), | |
| 10 | + reason TEXT NOT NULL, last_activity TEXT NOT NULL, last_actor_id TEXT REFERENCES users(id), | |
| 11 | + unread INTEGER NOT NULL DEFAULT 1, updated_at TEXT NOT NULL, UNIQUE (user_id, issue_id)); | |
| 12 | +CREATE INDEX notifications_inbox ON notifications(user_id, unread, updated_at); |
crates/forge-core/migrations/0013_commit_status.sql| @@ -0,0 +1,20 @@ | ||
| 1 | +-- Commit statuses reported by CI runners (and people) through the token API. Applied by | |
| 2 | +-- store/migrate.rs. | |
| 3 | +-- One row per (repo, commit, context): posting again for the same context replaces the row, so the | |
| 4 | +-- latest report wins. sha is the full lowercase hex object id (40 or 64 characters). | |
| 5 | +-- state is pending, success, failure or error. Timestamps are RFC 3339 UTC stamps from time.rs. | |
| 6 | + | |
| 7 | +CREATE TABLE commit_statuses ( | |
| 8 | + id TEXT PRIMARY KEY, | |
| 9 | + repo_id TEXT NOT NULL REFERENCES repos(id), | |
| 10 | + sha TEXT NOT NULL, | |
| 11 | + context TEXT NOT NULL DEFAULT 'default', | |
| 12 | + state TEXT NOT NULL CHECK (state IN ('pending', 'success', 'failure', 'error')), | |
| 13 | + description TEXT NULL, | |
| 14 | + target_url TEXT NULL, | |
| 15 | + creator_id TEXT NOT NULL REFERENCES users(id), | |
| 16 | + created_at TEXT NOT NULL, | |
| 17 | + updated_at TEXT NOT NULL, | |
| 18 | + UNIQUE (repo_id, sha, context) | |
| 19 | +); | |
| 20 | +CREATE INDEX commit_statuses_repo_sha ON commit_statuses(repo_id, sha); |
crates/forge-core/migrations/0014_backup_runs.sql| @@ -0,0 +1,19 @@ | ||
| 1 | +-- Scheduled and manual backup and fsck runs, shown on /admin/backups. Applied by store/migrate.rs. | |
| 2 | +-- kind: 'backup' (a backup set) or 'fsck' (git fsck of every repo plus the database check). | |
| 3 | +-- trigger: 'schedule' (the scheduler), 'manual' (the admin page) or 'cli' (forge-web admin backup). | |
| 4 | +-- finished_at and ok stay NULL while a run is in progress; a run that never finished is shown as failed. | |
| 5 | +-- path and bytes are set for a backup that finished; error holds the first failure. | |
| 6 | +-- Timestamps are RFC 3339 UTC stamps from time.rs. | |
| 7 | + | |
| 8 | +CREATE TABLE backup_runs ( | |
| 9 | + id TEXT PRIMARY KEY, | |
| 10 | + kind TEXT NOT NULL CHECK (kind IN ('backup', 'fsck')), | |
| 11 | + started_at TEXT NOT NULL, | |
| 12 | + finished_at TEXT NULL, | |
| 13 | + ok INTEGER NULL CHECK (ok IS NULL OR ok IN (0, 1)), | |
| 14 | + path TEXT NULL, | |
| 15 | + bytes INTEGER NULL, | |
| 16 | + error TEXT NULL, | |
| 17 | + "trigger" TEXT NOT NULL CHECK ("trigger" IN ('schedule', 'manual', 'cli')) | |
| 18 | +); | |
| 19 | +CREATE INDEX backup_runs_kind_started ON backup_runs(kind, started_at); |
crates/forge-core/migrations/0015_reactions.sql| @@ -0,0 +1,14 @@ | ||
| 1 | +-- Emoji reactions on issues (and pull requests, which are issue rows) and on their comments. | |
| 2 | +-- Applied by store/migrate.rs. target_kind is 'issue' or 'comment'. reaction is the short name of | |
| 3 | +-- a forge_core::Reaction (for example '+1' or 'heart'). One row per user, target and emoji, so a | |
| 4 | +-- second toggle removes the row again. | |
| 5 | + | |
| 6 | +CREATE TABLE reactions ( | |
| 7 | + target_kind TEXT NOT NULL, | |
| 8 | + target_id TEXT NOT NULL, | |
| 9 | + user_id TEXT NOT NULL REFERENCES users(id), | |
| 10 | + emoji TEXT NOT NULL, | |
| 11 | + created_at TEXT NOT NULL, | |
| 12 | + PRIMARY KEY (target_kind, target_id, user_id, emoji) | |
| 13 | +); | |
| 14 | +CREATE INDEX reactions_target ON reactions(target_kind, target_id); |
crates/forge-core/migrations/0016_review_remap.sql| @@ -0,0 +1,3 @@ | ||
| 1 | +-- Review line remapping and dismissal history. | |
| 2 | +-- dismissed_from keeps the state a review had before it was dismissed (approved or changes_requested). | |
| 3 | +ALTER TABLE pull_reviews ADD COLUMN dismissed_from TEXT; |
crates/forge-core/migrations/0017_mirrors.sql| @@ -0,0 +1,26 @@ | ||
| 1 | +-- Push and pull mirrors of a repo. Applied by store/migrate.rs. | |
| 2 | +-- direction: 'pull' (the repo's refs come from url) or 'push' (our refs go to url). | |
| 3 | +-- At most one pull mirror per repo (partial unique index); at most 10 push mirrors (enforced | |
| 4 | +-- by the store). | |
| 5 | +-- secret_sealed holds the secret::seal output as bytes, as webhooks.secret_sealed does. NULL | |
| 6 | +-- means no password. The plaintext secret never reaches the database. | |
| 7 | +-- Timestamps are RFC 3339 UTC stamps from time.rs, so text comparison is time order. | |
| 8 | + | |
| 9 | +CREATE TABLE repo_mirrors ( | |
| 10 | + id TEXT PRIMARY KEY, | |
| 11 | + repo_id TEXT NOT NULL REFERENCES repos(id), | |
| 12 | + direction TEXT NOT NULL CHECK (direction IN ('pull', 'push')), | |
| 13 | + url TEXT NOT NULL, | |
| 14 | + username TEXT NOT NULL DEFAULT '', | |
| 15 | + secret_sealed BLOB, | |
| 16 | + interval_minutes INTEGER NOT NULL DEFAULT 480, | |
| 17 | + enabled INTEGER NOT NULL DEFAULT 1, | |
| 18 | + next_sync_at TEXT NOT NULL, | |
| 19 | + last_sync_at TEXT, | |
| 20 | + last_ok INTEGER, | |
| 21 | + last_error TEXT, | |
| 22 | + created_at TEXT NOT NULL | |
| 23 | +); | |
| 24 | +CREATE INDEX repo_mirrors_due ON repo_mirrors(enabled, next_sync_at); | |
| 25 | +CREATE UNIQUE INDEX repo_mirrors_one_pull ON repo_mirrors(repo_id) WHERE direction = 'pull'; | |
| 26 | +CREATE INDEX repo_mirrors_repo ON repo_mirrors(repo_id); |
crates/forge-core/migrations/0018_issue_lock.sql| @@ -0,0 +1,6 @@ | ||
| 1 | +-- Conversation locking on issues and pull requests (they share the issues table). While locked, | |
| 2 | +-- only collaborators with Triage or higher may write to the conversation. lock_reason is one of | |
| 3 | +-- off_topic, too_heated, resolved or spam, or NULL when no reason was given. | |
| 4 | + | |
| 5 | +ALTER TABLE issues ADD COLUMN locked INTEGER NOT NULL DEFAULT 0; | |
| 6 | +ALTER TABLE issues ADD COLUMN lock_reason TEXT; |
crates/forge-core/migrations/0019_pinned_issues.sql| @@ -0,0 +1,11 @@ | ||
| 1 | +-- Pinned issues shown above the issue list of a repo. At most three per repo (enforced by | |
| 2 | +-- store/pinned.rs). position orders the cards; pinned_at is an RFC 3339 UTC stamp from time.rs. | |
| 3 | +-- Only issues are pinned, never pull requests. | |
| 4 | + | |
| 5 | +CREATE TABLE pinned_issues ( | |
| 6 | + repo_id TEXT NOT NULL REFERENCES repos(id), | |
| 7 | + issue_id TEXT NOT NULL REFERENCES issues(id), | |
| 8 | + position INTEGER NOT NULL, | |
| 9 | + pinned_at TEXT NOT NULL, | |
| 10 | + PRIMARY KEY (repo_id, issue_id) | |
| 11 | +); |
crates/forge-core/migrations/0020_releases.sql| @@ -0,0 +1,32 @@ | ||
| 1 | +-- Releases: a tag-anchored announcement with optional downloadable assets. A release points at | |
| 2 | +-- a tag (created as a lightweight tag on demand) and keeps its own title and body. Deleting a | |
| 3 | +-- release keeps the git tag. Drafts have published_at NULL and are visible only to writers. | |
| 4 | +-- Asset bytes live in the blob store under assets/<asset id>; only metadata is here. | |
| 5 | + | |
| 6 | +CREATE TABLE releases ( | |
| 7 | + id TEXT PRIMARY KEY, | |
| 8 | + repo_id TEXT NOT NULL REFERENCES repos(id), | |
| 9 | + tag TEXT NOT NULL, | |
| 10 | + target_oid TEXT NOT NULL, | |
| 11 | + title TEXT NOT NULL, | |
| 12 | + body TEXT NOT NULL DEFAULT '', | |
| 13 | + draft INTEGER NOT NULL DEFAULT 0, | |
| 14 | + prerelease INTEGER NOT NULL DEFAULT 0, | |
| 15 | + author_id TEXT NOT NULL REFERENCES users(id), | |
| 16 | + created_at TEXT NOT NULL, | |
| 17 | + published_at TEXT, | |
| 18 | + UNIQUE (repo_id, tag) | |
| 19 | +); | |
| 20 | + | |
| 21 | +CREATE TABLE release_assets ( | |
| 22 | + id TEXT PRIMARY KEY, | |
| 23 | + release_id TEXT NOT NULL REFERENCES releases(id) ON DELETE CASCADE, | |
| 24 | + name TEXT NOT NULL, | |
| 25 | + size INTEGER NOT NULL, | |
| 26 | + sha256 TEXT NOT NULL, | |
| 27 | + content_type TEXT NOT NULL, | |
| 28 | + download_count INTEGER NOT NULL DEFAULT 0, | |
| 29 | + uploader_id TEXT NOT NULL REFERENCES users(id), | |
| 30 | + created_at TEXT NOT NULL, | |
| 31 | + UNIQUE (release_id, name) | |
| 32 | +); |
crates/forge-core/migrations/0021_lfs.sql| @@ -0,0 +1,12 @@ | ||
| 1 | +-- Git LFS: which repo may use which content-addressed object. The bytes live on disk under | |
| 2 | +-- data_dir/lfs (shared by every repo); only the per-repo links are stored here. | |
| 3 | + | |
| 4 | +CREATE TABLE lfs_objects ( | |
| 5 | + repo_id TEXT NOT NULL REFERENCES repos(id), | |
| 6 | + oid TEXT NOT NULL, | |
| 7 | + size INTEGER NOT NULL, | |
| 8 | + created_at TEXT NOT NULL, | |
| 9 | + PRIMARY KEY (repo_id, oid) | |
| 10 | +); | |
| 11 | + | |
| 12 | +CREATE INDEX lfs_objects_oid ON lfs_objects (oid); |
crates/forge-core/migrations/0022_signatures.sql| @@ -0,0 +1,31 @@ | ||
| 1 | +-- GPG keys users added for commit verification, and the cached verification result per commit. | |
| 2 | +-- Applied by store/migrate.rs. Timestamps are RFC 3339 UTC stamps from time.rs. | |
| 3 | +-- The armored public key is stored as given. `emails` is a JSON array of the key's uid emails. | |
| 4 | +-- Rows are deleted with the user (user_delete.rs) and with the repo (Store::delete_repo). | |
| 5 | + | |
| 6 | +CREATE TABLE gpg_keys ( | |
| 7 | + id TEXT PRIMARY KEY, | |
| 8 | + user_id TEXT NOT NULL REFERENCES users(id), | |
| 9 | + fingerprint TEXT NOT NULL UNIQUE, | |
| 10 | + armored TEXT NOT NULL, | |
| 11 | + emails TEXT NOT NULL, | |
| 12 | + created_at TEXT NOT NULL | |
| 13 | +); | |
| 14 | + | |
| 15 | +CREATE INDEX gpg_keys_user ON gpg_keys (user_id); | |
| 16 | + | |
| 17 | +-- status: verified | unverified | unsigned | unknown_key | bad. kind: ssh | gpg, NULL when unsigned. | |
| 18 | +-- signer_user_id is set when the signing key is known to belong to a user. It has no foreign key | |
| 19 | +-- because user deletion clears these rows explicitly. | |
| 20 | +CREATE TABLE commit_signatures ( | |
| 21 | + repo_id TEXT NOT NULL REFERENCES repos(id), | |
| 22 | + oid TEXT NOT NULL, | |
| 23 | + status TEXT NOT NULL, | |
| 24 | + kind TEXT, | |
| 25 | + signer_user_id TEXT, | |
| 26 | + key_fingerprint TEXT, | |
| 27 | + checked_at TEXT NOT NULL, | |
| 28 | + PRIMARY KEY (repo_id, oid) | |
| 29 | +); | |
| 30 | + | |
| 31 | +CREATE INDEX commit_signatures_signer ON commit_signatures (signer_user_id); |
crates/forge-core/migrations/0023_replication.sql| @@ -0,0 +1,13 @@ | ||
| 1 | +-- Standby replication status, shown on /admin/replication. Applied by store/migrate.rs. | |
| 2 | +-- subject: 'db' for the database snapshot, or a repo id for a replicated repo. | |
| 3 | +-- last_ok_at is set by the last successful attempt; last_error holds the latest failure and is | |
| 4 | +-- cleared by a success. last_attempt_at is set by every attempt. refs_sha256 is the refs checksum | |
| 5 | +-- of the last successful repo push. Timestamps are RFC 3339 UTC stamps from time.rs. | |
| 6 | + | |
| 7 | +CREATE TABLE replication_status ( | |
| 8 | + subject TEXT PRIMARY KEY, | |
| 9 | + last_ok_at TEXT NULL, | |
| 10 | + last_error TEXT NULL, | |
| 11 | + last_attempt_at TEXT NULL, | |
| 12 | + refs_sha256 TEXT NULL | |
| 13 | +); |
crates/forge-core/migrations/0024_ci.sql| @@ -0,0 +1,77 @@ | ||
| 1 | +-- CI: runners, registration tokens, runs, jobs, artifacts and sealed repo secrets. | |
| 2 | +-- Runner, registration and job tokens are stored as sha256 hex only. Timestamps are RFC 3339 | |
| 3 | +-- UTC stamps from time.rs. See docs/ci-interfaces.md section 2. | |
| 4 | + | |
| 5 | +CREATE TABLE ci_runners ( | |
| 6 | + id TEXT PRIMARY KEY, | |
| 7 | + name TEXT NOT NULL, | |
| 8 | + labels TEXT NOT NULL, | |
| 9 | + secret_hash TEXT NOT NULL UNIQUE, | |
| 10 | + created_at TEXT NOT NULL, | |
| 11 | + last_seen_at TEXT NULL, | |
| 12 | + disabled INTEGER NOT NULL DEFAULT 0 | |
| 13 | +); | |
| 14 | + | |
| 15 | +CREATE TABLE ci_registration_tokens ( | |
| 16 | + token_hash TEXT PRIMARY KEY, | |
| 17 | + created_by TEXT NOT NULL REFERENCES users(id), | |
| 18 | + created_at TEXT NOT NULL, | |
| 19 | + expires_at TEXT NOT NULL, | |
| 20 | + used_at TEXT NULL | |
| 21 | +); | |
| 22 | + | |
| 23 | +CREATE TABLE ci_runs ( | |
| 24 | + id TEXT PRIMARY KEY, | |
| 25 | + repo_id TEXT NOT NULL REFERENCES repos(id), | |
| 26 | + number INTEGER NOT NULL, | |
| 27 | + trigger TEXT NOT NULL, | |
| 28 | + git_ref TEXT NOT NULL, | |
| 29 | + commit_oid TEXT NOT NULL, | |
| 30 | + pull_number INTEGER NULL, | |
| 31 | + actor_id TEXT NOT NULL REFERENCES users(id), | |
| 32 | + state TEXT NOT NULL, | |
| 33 | + config_error TEXT NULL, | |
| 34 | + created_at TEXT NOT NULL, | |
| 35 | + finished_at TEXT NULL, | |
| 36 | + UNIQUE (repo_id, number) | |
| 37 | +); | |
| 38 | + | |
| 39 | +CREATE TABLE ci_jobs ( | |
| 40 | + id TEXT PRIMARY KEY, | |
| 41 | + run_id TEXT NOT NULL REFERENCES ci_runs(id) ON DELETE CASCADE, | |
| 42 | + name TEXT NOT NULL, | |
| 43 | + spec TEXT NOT NULL, | |
| 44 | + state TEXT NOT NULL, | |
| 45 | + runner_id TEXT NULL, | |
| 46 | + job_token_hash TEXT NULL, | |
| 47 | + exit_code INTEGER NULL, | |
| 48 | + message TEXT NULL, | |
| 49 | + queued_at TEXT NULL, | |
| 50 | + started_at TEXT NULL, | |
| 51 | + finished_at TEXT NULL, | |
| 52 | + heartbeat_at TEXT NULL, | |
| 53 | + cancel_requested INTEGER NOT NULL DEFAULT 0, | |
| 54 | + log_bytes INTEGER NOT NULL DEFAULT 0, | |
| 55 | + UNIQUE (run_id, name) | |
| 56 | +); | |
| 57 | + | |
| 58 | +CREATE INDEX ci_jobs_queue ON ci_jobs(state, queued_at); | |
| 59 | + | |
| 60 | +CREATE TABLE ci_artifacts ( | |
| 61 | + id TEXT PRIMARY KEY, | |
| 62 | + job_id TEXT NOT NULL REFERENCES ci_jobs(id) ON DELETE CASCADE, | |
| 63 | + name TEXT NOT NULL, | |
| 64 | + size INTEGER NOT NULL, | |
| 65 | + sha256 TEXT NOT NULL, | |
| 66 | + created_at TEXT NOT NULL, | |
| 67 | + UNIQUE (job_id, name) | |
| 68 | +); | |
| 69 | + | |
| 70 | +CREATE TABLE ci_secrets ( | |
| 71 | + repo_id TEXT NOT NULL REFERENCES repos(id), | |
| 72 | + name TEXT NOT NULL, | |
| 73 | + sealed BLOB NOT NULL, | |
| 74 | + created_at TEXT NOT NULL, | |
| 75 | + updated_at TEXT NOT NULL, | |
| 76 | + PRIMARY KEY (repo_id, name) | |
| 77 | +); |
crates/forge-core/migrations/0025_orgs.sql| @@ -0,0 +1,22 @@ | ||
| 1 | +-- Organizations: an org is a row in `users` with kind = 'org'. That keeps one namespace (/{name} | |
| 2 | +-- works for both), keeps repos.owner_id -> users(id), and leaves every owner_name join unchanged. | |
| 3 | +-- Org rows get the unusable password hash "!org" and can never authenticate (see store/orgs.rs). | |
| 4 | + | |
| 5 | +ALTER TABLE users ADD COLUMN kind TEXT NOT NULL DEFAULT 'user' CHECK (kind IN ('user','org')); | |
| 6 | +ALTER TABLE users ADD COLUMN description TEXT; | |
| 7 | + | |
| 8 | +CREATE TABLE org_settings (org_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, | |
| 9 | + default_repo_role TEXT NOT NULL DEFAULT 'read' CHECK (default_repo_role IN ('none','read','write')), | |
| 10 | + members_can_create_repos INTEGER NOT NULL DEFAULT 1); | |
| 11 | +CREATE TABLE org_members (org_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 12 | + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 13 | + role TEXT NOT NULL CHECK (role IN ('owner','member')), created_at TEXT NOT NULL, | |
| 14 | + PRIMARY KEY (org_id, user_id)); | |
| 15 | +CREATE TABLE teams (id TEXT PRIMARY KEY, org_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 16 | + name TEXT NOT NULL, name_folded TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', | |
| 17 | + role TEXT NOT NULL CHECK (role IN ('read','triage','write','maintain','admin')), | |
| 18 | + all_repos INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, UNIQUE (org_id, name_folded)); | |
| 19 | +CREATE TABLE team_members (team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE, | |
| 20 | + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, PRIMARY KEY (team_id, user_id)); | |
| 21 | +CREATE TABLE team_repos (team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE, | |
| 22 | + repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, PRIMARY KEY (team_id, repo_id)); |
crates/forge-core/migrations/0026_mail.sql| @@ -0,0 +1,36 @@ | ||
| 1 | +-- Outgoing mail (M1). The SMTP account lives here, not in forge.toml, so admins change it on | |
| 2 | +-- /admin/mail. The password is sealed with the instance key (secret::seal). | |
| 3 | +CREATE TABLE mail_settings ( | |
| 4 | + id INTEGER PRIMARY KEY CHECK (id = 1), | |
| 5 | + host TEXT NOT NULL DEFAULT '', | |
| 6 | + port INTEGER NOT NULL DEFAULT 587 CHECK (port BETWEEN 1 AND 65535), | |
| 7 | + security TEXT NOT NULL DEFAULT 'starttls' CHECK (security IN ('starttls', 'tls', 'none')), | |
| 8 | + username TEXT NOT NULL DEFAULT '', | |
| 9 | + password_sealed BLOB, | |
| 10 | + from_addr TEXT NOT NULL DEFAULT '', | |
| 11 | + from_name TEXT NOT NULL DEFAULT '', | |
| 12 | + enabled INTEGER NOT NULL DEFAULT 0 CHECK (enabled IN (0, 1)), | |
| 13 | + updated_at TEXT | |
| 14 | +); | |
| 15 | +INSERT INTO mail_settings (id) VALUES (1); | |
| 16 | + | |
| 17 | +-- Durable outbox. `idem_key` makes enqueue idempotent. A row is due when `sent_at` is NULL and | |
| 18 | +-- `next_attempt_at` is set; `lease_until` keeps two passes from sending the same row. A row with | |
| 19 | +-- no `next_attempt_at` and no `sent_at` has given up. | |
| 20 | +CREATE TABLE mail_outbox ( | |
| 21 | + id TEXT PRIMARY KEY, | |
| 22 | + idem_key TEXT NOT NULL UNIQUE, | |
| 23 | + to_addr TEXT NOT NULL, | |
| 24 | + subject TEXT NOT NULL, | |
| 25 | + body_text TEXT NOT NULL, | |
| 26 | + created_at TEXT NOT NULL, | |
| 27 | + attempts INTEGER NOT NULL DEFAULT 0, | |
| 28 | + next_attempt_at TEXT, | |
| 29 | + lease_until TEXT, | |
| 30 | + sent_at TEXT, | |
| 31 | + last_error TEXT | |
| 32 | +); | |
| 33 | +CREATE INDEX mail_outbox_due ON mail_outbox (next_attempt_at); | |
| 34 | +CREATE INDEX mail_outbox_created ON mail_outbox (created_at); | |
| 35 | + | |
| 36 | +ALTER TABLE users ADD COLUMN email_verified_at TEXT; |
crates/forge-core/migrations/0027_wiki.sql| @@ -0,0 +1,4 @@ | ||
| 1 | +-- Wiki switch per repo. The wiki itself is a second bare repo on disk (see forge-git wiki.rs). | |
| 2 | +-- Applied by store/migrate.rs. | |
| 3 | + | |
| 4 | +ALTER TABLE repos ADD COLUMN wiki_enabled INTEGER NOT NULL DEFAULT 1; |
crates/forge-core/migrations/0028_discussions.sql| @@ -0,0 +1,43 @@ | ||
| 1 | +-- Discussions: a per-repo forum next to issues, opt-in per repo. Discussion numbers come from | |
| 2 | +-- repos.next_issue_number, the same counter as issues and pull requests, in the same transaction | |
| 3 | +-- as the insert (store/discussions.rs). Comments form one level of replies: a reply's parent_id is | |
| 4 | +-- a top-level comment of the same discussion. A deleted comment keeps its row (deleted_at is set, | |
| 5 | +-- the body is emptied) so its replies still make sense. answer_comment_id is only set in 'qa'. | |
| 6 | + | |
| 7 | +ALTER TABLE repos ADD COLUMN discussions_enabled INTEGER NOT NULL DEFAULT 0; | |
| 8 | + | |
| 9 | +CREATE TABLE discussions ( | |
| 10 | + id TEXT PRIMARY KEY, repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | |
| 11 | + number INTEGER NOT NULL, | |
| 12 | + category TEXT NOT NULL CHECK (category IN ('general','ideas','qa','announcements')), | |
| 13 | + title TEXT NOT NULL, body TEXT NOT NULL DEFAULT '', | |
| 14 | + author_id TEXT NOT NULL REFERENCES users(id), | |
| 15 | + answer_comment_id TEXT, | |
| 16 | + locked INTEGER NOT NULL DEFAULT 0, | |
| 17 | + comment_count INTEGER NOT NULL DEFAULT 0, | |
| 18 | + created_at TEXT NOT NULL, updated_at TEXT NOT NULL, | |
| 19 | + UNIQUE (repo_id, number)); | |
| 20 | +CREATE INDEX discussions_repo_category ON discussions(repo_id, category, number); | |
| 21 | + | |
| 22 | +CREATE TABLE discussion_comments ( | |
| 23 | + id TEXT PRIMARY KEY, | |
| 24 | + discussion_id TEXT NOT NULL REFERENCES discussions(id) ON DELETE CASCADE, | |
| 25 | + parent_id TEXT REFERENCES discussion_comments(id) ON DELETE CASCADE, | |
| 26 | + author_id TEXT NOT NULL REFERENCES users(id), | |
| 27 | + body TEXT NOT NULL DEFAULT '', | |
| 28 | + created_at TEXT NOT NULL, updated_at TEXT NOT NULL, | |
| 29 | + deleted_at TEXT); | |
| 30 | +CREATE INDEX discussion_comments_discussion ON discussion_comments(discussion_id, created_at); | |
| 31 | + | |
| 32 | +-- Inbox rows for @mentions in discussions. Issues use the notifications table, which points at | |
| 33 | +-- issues, so discussions get their own. One row per user and discussion. | |
| 34 | +CREATE TABLE discussion_notifications ( | |
| 35 | + user_id TEXT NOT NULL REFERENCES users(id), | |
| 36 | + discussion_id TEXT NOT NULL REFERENCES discussions(id) ON DELETE CASCADE, | |
| 37 | + repo_id TEXT NOT NULL REFERENCES repos(id), | |
| 38 | + reason TEXT NOT NULL DEFAULT 'mention', | |
| 39 | + last_actor_id TEXT REFERENCES users(id), | |
| 40 | + unread INTEGER NOT NULL DEFAULT 1, | |
| 41 | + updated_at TEXT NOT NULL, | |
| 42 | + PRIMARY KEY (user_id, discussion_id)); | |
| 43 | +CREATE INDEX discussion_notifications_inbox ON discussion_notifications(user_id, unread, updated_at); |
crates/forge-core/migrations/0029_snippets.sql| @@ -0,0 +1,29 @@ | ||
| 1 | +-- Snippets: small, shareable collections of text files owned by one user. Each save writes a | |
| 2 | +-- revision holding the whole set of files as JSON, so history never depends on later edits. | |
| 3 | +CREATE TABLE snippets ( | |
| 4 | + id TEXT PRIMARY KEY, | |
| 5 | + owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 6 | + title TEXT NOT NULL, | |
| 7 | + description TEXT NOT NULL DEFAULT '', | |
| 8 | + visibility TEXT NOT NULL CHECK (visibility IN ('public','unlisted','private')), | |
| 9 | + created_at TEXT NOT NULL, | |
| 10 | + updated_at TEXT NOT NULL | |
| 11 | +); | |
| 12 | +CREATE INDEX snippets_owner ON snippets (owner_id, updated_at); | |
| 13 | + | |
| 14 | +CREATE TABLE snippet_files ( | |
| 15 | + snippet_id TEXT NOT NULL REFERENCES snippets(id) ON DELETE CASCADE, | |
| 16 | + name TEXT NOT NULL, | |
| 17 | + content TEXT NOT NULL, | |
| 18 | + position INTEGER NOT NULL, | |
| 19 | + PRIMARY KEY (snippet_id, name) | |
| 20 | +); | |
| 21 | + | |
| 22 | +CREATE TABLE snippet_revisions ( | |
| 23 | + id TEXT PRIMARY KEY, | |
| 24 | + snippet_id TEXT NOT NULL REFERENCES snippets(id) ON DELETE CASCADE, | |
| 25 | + author_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 26 | + created_at TEXT NOT NULL, | |
| 27 | + files_json TEXT NOT NULL | |
| 28 | +); | |
| 29 | +CREATE INDEX snippet_revisions_snippet ON snippet_revisions (snippet_id, created_at); |
crates/forge-core/migrations/0030_ui_prefs.sql| @@ -0,0 +1,5 @@ | ||
| 1 | +-- UI preferences: colour theme and language per user. 'auto' follows the browser (theme) or | |
| 2 | +-- the instance default (language). Applied by store/migrate.rs. | |
| 3 | + | |
| 4 | +ALTER TABLE users ADD COLUMN theme TEXT NOT NULL DEFAULT 'auto' CHECK (theme IN ('auto','light','dark')); | |
| 5 | +ALTER TABLE users ADD COLUMN language TEXT NOT NULL DEFAULT 'auto'; |
crates/forge-core/migrations/0031_advisories.sql| @@ -0,0 +1,35 @@ | ||
| 1 | +-- Security advisories (OSV dumps), the dependencies read from each repo's lockfiles, and the | |
| 2 | +-- alerts that match them. See docs/wave-c-interfaces.md (A1). | |
| 3 | +CREATE TABLE advisories ( | |
| 4 | + id TEXT NOT NULL, | |
| 5 | + ecosystem TEXT NOT NULL, | |
| 6 | + package TEXT NOT NULL, | |
| 7 | + summary TEXT NOT NULL DEFAULT '', | |
| 8 | + severity TEXT NOT NULL DEFAULT '', | |
| 9 | + ranges_json TEXT NOT NULL DEFAULT '[]', | |
| 10 | + versions_json TEXT NOT NULL DEFAULT '[]', | |
| 11 | + modified TEXT NOT NULL DEFAULT '', | |
| 12 | + PRIMARY KEY (id, ecosystem, package) | |
| 13 | +); | |
| 14 | +CREATE INDEX advisories_by_package ON advisories (ecosystem, package); | |
| 15 | + | |
| 16 | +CREATE TABLE repo_dependencies ( | |
| 17 | + repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | |
| 18 | + ecosystem TEXT NOT NULL, | |
| 19 | + package TEXT NOT NULL, | |
| 20 | + version TEXT NOT NULL, | |
| 21 | + manifest_path TEXT NOT NULL, | |
| 22 | + PRIMARY KEY (repo_id, ecosystem, package, version, manifest_path) | |
| 23 | +); | |
| 24 | + | |
| 25 | +CREATE TABLE repo_alerts ( | |
| 26 | + repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | |
| 27 | + advisory_id TEXT NOT NULL, | |
| 28 | + ecosystem TEXT NOT NULL, | |
| 29 | + package TEXT NOT NULL, | |
| 30 | + version TEXT NOT NULL, | |
| 31 | + state TEXT NOT NULL CHECK (state IN ('open','dismissed','fixed')), | |
| 32 | + created_at TEXT NOT NULL, | |
| 33 | + updated_at TEXT NOT NULL, | |
| 34 | + PRIMARY KEY (repo_id, advisory_id, ecosystem, package, version) | |
| 35 | +); |
crates/forge-core/migrations/0032_repo_transfers.sql| @@ -0,0 +1,11 @@ | ||
| 1 | +-- Repo transfer acceptance (P1). A transfer started by a repo owner waits here until its target | |
| 2 | +-- accepts, declines, the initiator cancels, or it expires after 7 days. The target is a person, or | |
| 3 | +-- an org (any owner of the org may accept). Instance admin transfers skip this table. | |
| 4 | +-- Timestamps are RFC 3339 UTC stamps from time.rs. | |
| 5 | + | |
| 6 | +CREATE TABLE repo_transfers (repo_id TEXT PRIMARY KEY REFERENCES repos(id) ON DELETE CASCADE, | |
| 7 | + from_owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 8 | + to_owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 9 | + initiated_by TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 10 | + created_at TEXT NOT NULL, expires_at TEXT NOT NULL); | |
| 11 | +CREATE INDEX repo_transfers_to ON repo_transfers(to_owner_id); |
crates/forge-core/migrations/0033_account_tokens.sql| @@ -0,0 +1,33 @@ | ||
| 1 | +-- Account mail tokens: password reset, email verification and invites. A token is 32 random | |
| 2 | +-- bytes shown once in the mail link; only its sha256 is stored. `used_at` makes a link single | |
| 3 | +-- use, and `expires_at` bounds its life. | |
| 4 | + | |
| 5 | +CREATE TABLE password_reset_tokens ( | |
| 6 | + token_hash TEXT PRIMARY KEY, | |
| 7 | + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 8 | + created_at TEXT NOT NULL, | |
| 9 | + expires_at TEXT NOT NULL, | |
| 10 | + used_at TEXT | |
| 11 | +); | |
| 12 | +CREATE INDEX password_reset_tokens_user ON password_reset_tokens(user_id); | |
| 13 | + | |
| 14 | +CREATE TABLE email_verify_tokens ( | |
| 15 | + token_hash TEXT PRIMARY KEY, | |
| 16 | + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 17 | + email TEXT NOT NULL, | |
| 18 | + created_at TEXT NOT NULL, | |
| 19 | + expires_at TEXT NOT NULL, | |
| 20 | + used_at TEXT | |
| 21 | +); | |
| 22 | +CREATE INDEX email_verify_tokens_user ON email_verify_tokens(user_id); | |
| 23 | + | |
| 24 | +CREATE TABLE invites ( | |
| 25 | + token_hash TEXT PRIMARY KEY, | |
| 26 | + email TEXT NOT NULL, | |
| 27 | + invited_by TEXT REFERENCES users(id) ON DELETE SET NULL, | |
| 28 | + created_at TEXT NOT NULL, | |
| 29 | + expires_at TEXT NOT NULL, | |
| 30 | + accepted_at TEXT, | |
| 31 | + accepted_by TEXT REFERENCES users(id) ON DELETE SET NULL | |
| 32 | +); | |
| 33 | +CREATE INDEX invites_email ON invites(email); |
crates/forge-core/migrations/0034_advisory_notifications.sql| @@ -0,0 +1,12 @@ | ||
| 1 | +-- One inbox row per admin and repo, for security alerts a lockfile scan opened (see | |
| 2 | +-- docs/wave-c-interfaces.md, F-A). Alerts are not issues, so they cannot use the `notifications` | |
| 3 | +-- table, which points at issues. `new_alerts` counts the alerts opened since the row was last read. | |
| 4 | +CREATE TABLE advisory_notifications ( | |
| 5 | + user_id TEXT NOT NULL REFERENCES users(id), | |
| 6 | + repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | |
| 7 | + new_alerts INTEGER NOT NULL DEFAULT 0, | |
| 8 | + unread INTEGER NOT NULL DEFAULT 1, | |
| 9 | + updated_at TEXT NOT NULL, | |
| 10 | + PRIMARY KEY (user_id, repo_id) | |
| 11 | +); | |
| 12 | +CREATE INDEX advisory_notifications_inbox ON advisory_notifications (user_id, unread, updated_at); |
crates/forge-core/migrations/0035_email_notifications.sql| @@ -0,0 +1,6 @@ | ||
| 1 | +-- Email copies of notifications (M3). `email_notifications` is the per-user opt-out, on by | |
| 2 | +-- default. `message_id` and `in_reply_to` let mail clients thread the messages of one issue or | |
| 3 | +-- pull request: the first mail of a thread sets the root id, and later mails reference it. | |
| 4 | +ALTER TABLE users ADD COLUMN email_notifications INTEGER NOT NULL DEFAULT 1; | |
| 5 | +ALTER TABLE mail_outbox ADD COLUMN message_id TEXT; | |
| 6 | +ALTER TABLE mail_outbox ADD COLUMN in_reply_to TEXT; |
crates/forge-core/src/advisories.rs| @@ -0,0 +1,594 @@ | ||
| 1 | +//! Security advisories: the OSV records read from a dump, the version ranges they name, and the | |
| 2 | +//! lockfile parsers that turn a repo's `Cargo.lock`, `package-lock.json` and `go.sum` into | |
| 3 | +//! dependencies. Storage is in `store/advisories.rs`; the download and the scan are in forge-web. | |
| 4 | +//! | |
| 5 | +//! Only the OSV fields the forge needs are read: `id`, `summary`, `aliases`, `severity`, | |
| 6 | +//! `affected[].package`, `affected[].ranges` (SEMVER and ECOSYSTEM events) and | |
| 7 | +//! `affected[].versions`. Git ranges are skipped. Versions are compared as semver: a leading `v` | |
| 8 | +//! is dropped first, so Go versions and Go pseudo-versions compare as semver too. | |
| 9 | + | |
| 10 | +use std::collections::{BTreeMap, BTreeSet}; | |
| 11 | +use std::io::{Read, Seek}; | |
| 12 | + | |
| 13 | +use serde::{Deserialize, Serialize}; | |
| 14 | +use serde_json::Value; | |
| 15 | + | |
| 16 | +use crate::error::{Error, Result}; | |
| 17 | + | |
| 18 | +/// The OSV ecosystem names the forge knows how to scan. `[advisories] ecosystems` picks a subset. | |
| 19 | +pub const KNOWN_ECOSYSTEMS: [&str; 3] = ["crates.io", "npm", "Go"]; | |
| 20 | + | |
| 21 | +/// Largest single OSV record read from a dump. Real records are a few KiB. | |
| 22 | +const MAX_RECORD_BYTES: u64 = 4 * 1024 * 1024; | |
| 23 | + | |
| 24 | +/// One event of a range, in the order it appears in the record. | |
| 25 | +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] | |
| 26 | +#[serde(rename_all = "snake_case")] | |
| 27 | +pub enum Event { | |
| 28 | + Introduced(String), | |
| 29 | + Fixed(String), | |
| 30 | + LastAffected(String), | |
| 31 | + Limit(String), | |
| 32 | +} | |
| 33 | + | |
| 34 | +/// One SEMVER or ECOSYSTEM range of an affected package. | |
| 35 | +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] | |
| 36 | +pub struct Range { | |
| 37 | + pub events: Vec<Event>, | |
| 38 | +} | |
| 39 | + | |
| 40 | +/// An advisory as it applies to one package in one ecosystem. | |
| 41 | +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] | |
| 42 | +pub struct Advisory { | |
| 43 | + pub id: String, | |
| 44 | + pub ecosystem: String, | |
| 45 | + pub package: String, | |
| 46 | + pub summary: String, | |
| 47 | + pub severity: String, | |
| 48 | + pub modified: String, | |
| 49 | + #[serde(default)] | |
| 50 | + pub aliases: Vec<String>, | |
| 51 | + pub ranges: Vec<Range>, | |
| 52 | + pub versions: Vec<String>, | |
| 53 | +} | |
| 54 | + | |
| 55 | +/// A package version read from a lockfile. | |
| 56 | +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] | |
| 57 | +pub struct Dependency { | |
| 58 | + pub ecosystem: String, | |
| 59 | + pub package: String, | |
| 60 | + pub version: String, | |
| 61 | + pub manifest_path: String, | |
| 62 | +} | |
| 63 | + | |
| 64 | +#[derive(Deserialize)] | |
| 65 | +struct Record { | |
| 66 | + id: String, | |
| 67 | + #[serde(default)] | |
| 68 | + summary: String, | |
| 69 | + #[serde(default)] | |
| 70 | + aliases: Vec<String>, | |
| 71 | + #[serde(default)] | |
| 72 | + modified: String, | |
| 73 | + #[serde(default)] | |
| 74 | + severity: Vec<SeverityEntry>, | |
| 75 | + #[serde(default)] | |
| 76 | + database_specific: Value, | |
| 77 | + #[serde(default)] | |
| 78 | + affected: Vec<Affected>, | |
| 79 | +} | |
| 80 | + | |
| 81 | +#[derive(Deserialize)] | |
| 82 | +struct SeverityEntry { | |
| 83 | + #[serde(default)] | |
| 84 | + score: String, | |
| 85 | +} | |
| 86 | + | |
| 87 | +#[derive(Deserialize)] | |
| 88 | +struct Affected { | |
| 89 | + #[serde(default)] | |
| 90 | + package: Option<PackageRef>, | |
| 91 | + #[serde(default)] | |
| 92 | + ranges: Vec<RawRange>, | |
| 93 | + #[serde(default)] | |
| 94 | + versions: Vec<String>, | |
| 95 | +} | |
| 96 | + | |
| 97 | +#[derive(Deserialize)] | |
| 98 | +struct PackageRef { | |
| 99 | + #[serde(default)] | |
| 100 | + ecosystem: String, | |
| 101 | + #[serde(default)] | |
| 102 | + name: String, | |
| 103 | +} | |
| 104 | + | |
| 105 | +#[derive(Deserialize)] | |
| 106 | +struct RawRange { | |
| 107 | + #[serde(rename = "type", default)] | |
| 108 | + kind: String, | |
| 109 | + #[serde(default)] | |
| 110 | + events: Vec<RawEvent>, | |
| 111 | +} | |
| 112 | + | |
| 113 | +#[derive(Deserialize)] | |
| 114 | +struct RawEvent { | |
| 115 | + introduced: Option<String>, | |
| 116 | + fixed: Option<String>, | |
| 117 | + last_affected: Option<String>, | |
| 118 | + limit: Option<String>, | |
| 119 | +} | |
| 120 | + | |
| 121 | +/// Parses one OSV record into one [`Advisory`] per affected package of `ecosystem`. Several | |
| 122 | +/// `affected` entries for the same package are merged into one advisory. | |
| 123 | +pub fn parse_record(json: &[u8], ecosystem: &str) -> Result<Vec<Advisory>> { | |
| 124 | + let record: Record = serde_json::from_slice(json) | |
| 125 | + .map_err(|e| Error::Invalid(format!("advisory record is not valid OSV: {e}")))?; | |
| 126 | + if record.id.is_empty() { | |
| 127 | + return Err(Error::Invalid("advisory record has no id".into())); | |
| 128 | + } | |
| 129 | + let severity = record | |
| 130 | + .database_specific | |
| 131 | + .get("severity") | |
| 132 | + .and_then(Value::as_str) | |
| 133 | + .map(str::to_owned) | |
| 134 | + .or_else(|| record.severity.first().map(|s| s.score.clone())) | |
| 135 | + .unwrap_or_default(); | |
| 136 | + let mut merged: BTreeMap<String, Advisory> = BTreeMap::new(); | |
| 137 | + for affected in record.affected { | |
| 138 | + let Some(package) = affected.package else { | |
| 139 | + continue; | |
| 140 | + }; | |
| 141 | + if package.ecosystem != ecosystem || package.name.is_empty() { | |
| 142 | + continue; | |
| 143 | + } | |
| 144 | + let entry = merged | |
| 145 | + .entry(package.name.clone()) | |
| 146 | + .or_insert_with(|| Advisory { | |
| 147 | + id: record.id.clone(), | |
| 148 | + ecosystem: ecosystem.to_owned(), | |
| 149 | + package: package.name.clone(), | |
| 150 | + summary: record.summary.clone(), | |
| 151 | + severity: severity.clone(), | |
| 152 | + modified: record.modified.clone(), | |
| 153 | + aliases: record.aliases.clone(), | |
| 154 | + ranges: Vec::new(), | |
| 155 | + versions: Vec::new(), | |
| 156 | + }); | |
| 157 | + for raw in affected.ranges { | |
| 158 | + if !raw.kind.eq_ignore_ascii_case("SEMVER") | |
| 159 | + && !raw.kind.eq_ignore_ascii_case("ECOSYSTEM") | |
| 160 | + { | |
| 161 | + continue; | |
| 162 | + } | |
| 163 | + let events = raw | |
| 164 | + .events | |
| 165 | + .into_iter() | |
| 166 | + .filter_map(|e| { | |
| 167 | + if let Some(v) = e.introduced { | |
| 168 | + Some(Event::Introduced(v)) | |
| 169 | + } else if let Some(v) = e.fixed { | |
| 170 | + Some(Event::Fixed(v)) | |
| 171 | + } else if let Some(v) = e.last_affected { | |
| 172 | + Some(Event::LastAffected(v)) | |
| 173 | + } else { | |
| 174 | + e.limit.map(Event::Limit) | |
| 175 | + } | |
| 176 | + }) | |
| 177 | + .collect(); | |
| 178 | + entry.ranges.push(Range { events }); | |
| 179 | + } | |
| 180 | + entry.versions.extend(affected.versions); | |
| 181 | + } | |
| 182 | + Ok(merged.into_values().collect()) | |
| 183 | +} | |
| 184 | + | |
| 185 | +/// Reads every `.json` record of an OSV zip dump for `ecosystem`. A record that does not parse, | |
| 186 | +/// or is too large, is skipped and counted in the second value. | |
| 187 | +pub fn read_dump<R: Read + Seek>(reader: R, ecosystem: &str) -> Result<(Vec<Advisory>, usize)> { | |
| 188 | + let mut zip = zip::ZipArchive::new(reader) | |
| 189 | + .map_err(|e| Error::Invalid(format!("advisory dump is not a zip file: {e}")))?; | |
| 190 | + let mut advisories = Vec::new(); | |
| 191 | + let mut skipped = 0; | |
| 192 | + for index in 0..zip.len() { | |
| 193 | + let mut file = zip | |
| 194 | + .by_index(index) | |
| 195 | + .map_err(|e| Error::Invalid(format!("advisory dump entry unreadable: {e}")))?; | |
| 196 | + if file.is_dir() || !file.name().is_ok_and(|name| name.ends_with(".json")) { | |
| 197 | + continue; | |
| 198 | + } | |
| 199 | + let mut buf = Vec::new(); | |
| 200 | + file.by_ref() | |
| 201 | + .take(MAX_RECORD_BYTES + 1) | |
| 202 | + .read_to_end(&mut buf)?; | |
| 203 | + if buf.len() as u64 > MAX_RECORD_BYTES { | |
| 204 | + skipped += 1; | |
| 205 | + continue; | |
| 206 | + } | |
| 207 | + match parse_record(&buf, ecosystem) { | |
| 208 | + Ok(found) => advisories.extend(found), | |
| 209 | + Err(_) => skipped += 1, | |
| 210 | + } | |
| 211 | + } | |
| 212 | + Ok((advisories, skipped)) | |
| 213 | +} | |
| 214 | + | |
| 215 | +/// Parses a version as semver, after dropping a leading `v` (Go style). | |
| 216 | +pub fn parse_version(raw: &str) -> Option<semver::Version> { | |
| 217 | + semver::Version::parse(raw.trim().trim_start_matches('v')).ok() | |
| 218 | +} | |
| 219 | + | |
| 220 | +/// Whether `advisory` covers `version` of its package. A version listed in `versions` matches by | |
| 221 | +/// text. Otherwise the version must fall inside one of the ranges. | |
| 222 | +pub fn affects(advisory: &Advisory, version: &str) -> bool { | |
| 223 | + let wanted = version.trim().trim_start_matches('v'); | |
| 224 | + if advisory | |
| 225 | + .versions | |
| 226 | + .iter() | |
| 227 | + .any(|v| v.trim().trim_start_matches('v') == wanted) | |
| 228 | + { | |
| 229 | + return true; | |
| 230 | + } | |
| 231 | + let Some(parsed) = parse_version(version) else { | |
| 232 | + return false; | |
| 233 | + }; | |
| 234 | + advisory | |
| 235 | + .ranges | |
| 236 | + .iter() | |
| 237 | + .any(|range| range_affects(range, &parsed)) | |
| 238 | +} | |
| 239 | + | |
| 240 | +/// Walks the events of one range. An interval runs from an `introduced` event to the next | |
| 241 | +/// `fixed` (exclusive) or `last_affected` (inclusive). `introduced` of `0` starts at the beginning. | |
| 242 | +/// An interval still open at the end runs forever. | |
| 243 | +fn range_affects(range: &Range, version: &semver::Version) -> bool { | |
| 244 | + // `Some(start)` while an interval is open; `start` is `None` when it began at `0`. | |
| 245 | + let mut open: Option<Option<semver::Version>> = None; | |
| 246 | + for event in &range.events { | |
| 247 | + match event { | |
| 248 | + Event::Introduced(raw) => { | |
| 249 | + open = Some(if raw == "0" { | |
| 250 | + None | |
| 251 | + } else { | |
| 252 | + // An unreadable start is treated as "from the beginning": more alerts, never fewer. | |
| 253 | + parse_version(raw) | |
| 254 | + }); | |
| 255 | + } | |
| 256 | + Event::Fixed(raw) => { | |
| 257 | + if let Some(start) = open.take() | |
| 258 | + && starts_before(&start, version) | |
| 259 | + && parse_version(raw).is_some_and(|fixed| *version < fixed) | |
| 260 | + { | |
| 261 | + return true; | |
| 262 | + } | |
| 263 | + } | |
| 264 | + Event::LastAffected(raw) => { | |
| 265 | + if let Some(start) = open.take() | |
| 266 | + && starts_before(&start, version) | |
| 267 | + && parse_version(raw).is_some_and(|last| *version <= last) | |
| 268 | + { | |
| 269 | + return true; | |
| 270 | + } | |
| 271 | + } | |
| 272 | + Event::Limit(_) => {} | |
| 273 | + } | |
| 274 | + } | |
| 275 | + open.is_some_and(|start| starts_before(&start, version)) | |
| 276 | +} | |
| 277 | + | |
| 278 | +fn starts_before(start: &Option<semver::Version>, version: &semver::Version) -> bool { | |
| 279 | + start.as_ref().is_none_or(|s| version >= s) | |
| 280 | +} | |
| 281 | + | |
| 282 | +/// Reads `Cargo.lock`. Only registry packages count: path and git packages have no `source` | |
| 283 | +/// from crates.io and are skipped. | |
| 284 | +pub fn parse_cargo_lock(text: &str, manifest_path: &str) -> Result<Vec<Dependency>> { | |
| 285 | + #[derive(Deserialize)] | |
| 286 | + struct Lock { | |
| 287 | + #[serde(default)] | |
| 288 | + package: Vec<Package>, | |
| 289 | + } | |
| 290 | + #[derive(Deserialize)] | |
| 291 | + struct Package { | |
| 292 | + name: String, | |
| 293 | + version: String, | |
| 294 | + #[serde(default)] | |
| 295 | + source: Option<String>, | |
| 296 | + } | |
| 297 | + let lock: Lock = toml::from_str(text) | |
| 298 | + .map_err(|e| Error::Invalid(format!("Cargo.lock is not valid TOML: {e}")))?; | |
| 299 | + Ok(lock | |
| 300 | + .package | |
| 301 | + .into_iter() | |
| 302 | + .filter(|p| { | |
| 303 | + p.source | |
| 304 | + .as_deref() | |
| 305 | + .is_some_and(|s| s.starts_with("registry+")) | |
| 306 | + }) | |
| 307 | + .map(|p| dependency("crates.io", p.name, p.version, manifest_path)) | |
| 308 | + .collect()) | |
| 309 | +} | |
| 310 | + | |
| 311 | +/// Reads `package-lock.json` in the v2 and v3 `packages` layout. Entries without a version, the | |
| 312 | +/// root entry and linked workspace packages are skipped. | |
| 313 | +pub fn parse_package_lock(text: &str, manifest_path: &str) -> Result<Vec<Dependency>> { | |
| 314 | + let doc: Value = serde_json::from_str(text) | |
| 315 | + .map_err(|e| Error::Invalid(format!("package-lock.json is not valid JSON: {e}")))?; | |
| 316 | + let Some(packages) = doc.get("packages").and_then(Value::as_object) else { | |
| 317 | + return Ok(Vec::new()); | |
| 318 | + }; | |
| 319 | + let mut out = Vec::new(); | |
| 320 | + for (key, entry) in packages { | |
| 321 | + if key.is_empty() || entry.get("link").and_then(Value::as_bool) == Some(true) { | |
| 322 | + continue; | |
| 323 | + } | |
| 324 | + let Some(version) = entry.get("version").and_then(Value::as_str) else { | |
| 325 | + continue; | |
| 326 | + }; | |
| 327 | + // `node_modules/a/node_modules/@s/b` names `@s/b`: the part after the last `node_modules/`. | |
| 328 | + let name = match key.rsplit_once("node_modules/") { | |
| 329 | + Some((_, name)) => name, | |
| 330 | + None => continue, | |
| 331 | + }; | |
| 332 | + out.push(dependency( | |
| 333 | + "npm", | |
| 334 | + name.to_owned(), | |
| 335 | + version.to_owned(), | |
| 336 | + manifest_path, | |
| 337 | + )); | |
| 338 | + } | |
| 339 | + Ok(out) | |
| 340 | +} | |
| 341 | + | |
| 342 | +/// Reads `go.sum`. Each module version is listed once, with or without its `/go.mod` hash line; | |
| 343 | +/// the `/go.mod` lines alone are not dependencies, so they are dropped. | |
| 344 | +pub fn parse_go_sum(text: &str, manifest_path: &str) -> Vec<Dependency> { | |
| 345 | + let mut seen = BTreeSet::new(); | |
| 346 | + for line in text.lines() { | |
| 347 | + let mut fields = line.split_whitespace(); | |
| 348 | + let (Some(module), Some(version)) = (fields.next(), fields.next()) else { | |
| 349 | + continue; | |
| 350 | + }; | |
| 351 | + if version.ends_with("/go.mod") { | |
| 352 | + continue; | |
| 353 | + } | |
| 354 | + seen.insert((module.to_owned(), version.to_owned())); | |
| 355 | + } | |
| 356 | + seen.into_iter() | |
| 357 | + .map(|(module, version)| dependency("Go", module, version, manifest_path)) | |
| 358 | + .collect() | |
| 359 | +} | |
| 360 | + | |
| 361 | +fn dependency( | |
| 362 | + ecosystem: &str, | |
| 363 | + package: String, | |
| 364 | + version: String, | |
| 365 | + manifest_path: &str, | |
| 366 | +) -> Dependency { | |
| 367 | + Dependency { | |
| 368 | + ecosystem: ecosystem.to_owned(), | |
| 369 | + package, | |
| 370 | + version, | |
| 371 | + manifest_path: manifest_path.to_owned(), | |
| 372 | + } | |
| 373 | +} | |
| 374 | + | |
| 375 | +#[cfg(test)] | |
| 376 | +mod tests { | |
| 377 | + use super::*; | |
| 378 | + | |
| 379 | + fn adv(ranges: Vec<Range>, versions: Vec<&str>) -> Advisory { | |
| 380 | + Advisory { | |
| 381 | + id: "GHSA-test".into(), | |
| 382 | + ecosystem: "crates.io".into(), | |
| 383 | + package: "demo".into(), | |
| 384 | + summary: String::new(), | |
| 385 | + severity: String::new(), | |
| 386 | + modified: String::new(), | |
| 387 | + aliases: Vec::new(), | |
| 388 | + ranges, | |
| 389 | + versions: versions.into_iter().map(str::to_owned).collect(), | |
| 390 | + } | |
| 391 | + } | |
| 392 | + | |
| 393 | + fn range(events: Vec<Event>) -> Range { | |
| 394 | + Range { events } | |
| 395 | + } | |
| 396 | + | |
| 397 | + #[test] | |
| 398 | + fn introduced_and_fixed_is_half_open() { | |
| 399 | + let a = adv( | |
| 400 | + vec![range(vec![ | |
| 401 | + Event::Introduced("1.0.0".into()), | |
| 402 | + Event::Fixed("1.2.0".into()), | |
| 403 | + ])], | |
| 404 | + vec![], | |
| 405 | + ); | |
| 406 | + assert!(!affects(&a, "0.9.9")); | |
| 407 | + assert!(affects(&a, "1.0.0")); | |
| 408 | + assert!(affects(&a, "1.1.5")); | |
| 409 | + assert!(!affects(&a, "1.2.0")); | |
| 410 | + assert!(!affects(&a, "2.0.0")); | |
| 411 | + } | |
| 412 | + | |
| 413 | + #[test] | |
| 414 | + fn introduced_zero_with_last_affected_is_inclusive() { | |
| 415 | + let a = adv( | |
| 416 | + vec![range(vec![ | |
| 417 | + Event::Introduced("0".into()), | |
| 418 | + Event::LastAffected("0.4.7".into()), | |
| 419 | + ])], | |
| 420 | + vec![], | |
| 421 | + ); | |
| 422 | + assert!(affects(&a, "0.0.1")); | |
| 423 | + assert!(affects(&a, "0.4.7")); | |
| 424 | + assert!(!affects(&a, "0.4.8")); | |
| 425 | + } | |
| 426 | + | |
| 427 | + #[test] | |
| 428 | + fn open_interval_runs_forever_and_later_intervals_work() { | |
| 429 | + let a = adv( | |
| 430 | + vec![range(vec![ | |
| 431 | + Event::Introduced("1.0.0".into()), | |
| 432 | + Event::Fixed("1.1.0".into()), | |
| 433 | + Event::Introduced("2.0.0".into()), | |
| 434 | + ])], | |
| 435 | + vec![], | |
| 436 | + ); | |
| 437 | + assert!(affects(&a, "1.0.5")); | |
| 438 | + assert!(!affects(&a, "1.5.0")); | |
| 439 | + assert!(affects(&a, "9.9.9")); | |
| 440 | + } | |
| 441 | + | |
| 442 | + #[test] | |
| 443 | + fn go_v_prefix_and_pseudo_versions_compare_as_semver() { | |
| 444 | + let a = adv( | |
| 445 | + vec![range(vec![ | |
| 446 | + Event::Introduced("0".into()), | |
| 447 | + Event::Fixed("1.9.1".into()), | |
| 448 | + ])], | |
| 449 | + vec![], | |
| 450 | + ); | |
| 451 | + assert!(affects(&a, "v1.9.0")); | |
| 452 | + assert!(!affects(&a, "v1.9.1")); | |
| 453 | + assert!(affects(&a, "v0.0.0-20210101000000-abcdef123456")); | |
| 454 | + } | |
| 455 | + | |
| 456 | + #[test] | |
| 457 | + fn listed_versions_match_by_text() { | |
| 458 | + let a = adv(vec![], vec!["1.0.3", "v2.0.0"]); | |
| 459 | + assert!(affects(&a, "1.0.3")); | |
| 460 | + assert!(affects(&a, "2.0.0")); | |
| 461 | + assert!(!affects(&a, "1.0.4")); | |
| 462 | + } | |
| 463 | + | |
| 464 | + #[test] | |
| 465 | + fn unparsable_version_matches_nothing_by_range() { | |
| 466 | + let a = adv(vec![range(vec![Event::Introduced("0".into())])], vec![]); | |
| 467 | + assert!(!affects(&a, "not-a-version")); | |
| 468 | + } | |
| 469 | + | |
| 470 | + #[test] | |
| 471 | + fn parses_an_osv_record_and_merges_entries_for_one_package() { | |
| 472 | + let json = br#"{ | |
| 473 | + "id": "GHSA-xxxx", | |
| 474 | + "summary": "bad things", | |
| 475 | + "modified": "2026-01-01T00:00:00Z", | |
| 476 | + "database_specific": {"severity": "HIGH"}, | |
| 477 | + "affected": [ | |
| 478 | + {"package": {"ecosystem": "crates.io", "name": "demo"}, | |
| 479 | + "ranges": [{"type": "SEMVER", "events": [{"introduced": "0"}, {"fixed": "1.0.1"}]}]}, | |
| 480 | + {"package": {"ecosystem": "crates.io", "name": "demo"}, | |
| 481 | + "ranges": [{"type": "GIT", "events": [{"introduced": "0"}]}, | |
| 482 | + {"type": "ECOSYSTEM", "events": [{"introduced": "3.0.0"}, {"last_affected": "3.0.5"}]}], | |
| 483 | + "versions": ["0.9.0"]}, | |
| 484 | + {"package": {"ecosystem": "npm", "name": "other"}, | |
| 485 | + "ranges": []} | |
| 486 | + ] | |
| 487 | + }"#; | |
| 488 | + let found = parse_record(json, "crates.io").expect("record parses"); | |
| 489 | + assert_eq!(found.len(), 1); | |
| 490 | + let a = &found[0]; | |
| 491 | + assert_eq!(a.id, "GHSA-xxxx"); | |
| 492 | + assert_eq!(a.severity, "HIGH"); | |
| 493 | + assert_eq!(a.ranges.len(), 2, "the GIT range is skipped"); | |
| 494 | + assert!(affects(a, "0.5.0")); | |
| 495 | + assert!(!affects(a, "1.0.1")); | |
| 496 | + assert!(affects(a, "3.0.2")); | |
| 497 | + assert!(affects(a, "0.9.0")); | |
| 498 | + } | |
| 499 | + | |
| 500 | + #[test] | |
| 501 | + fn record_without_an_id_is_refused() { | |
| 502 | + assert!(parse_record(br#"{"affected": []}"#, "npm").is_err()); | |
| 503 | + assert!(parse_record(b"not json", "npm").is_err()); | |
| 504 | + } | |
| 505 | + | |
| 506 | + #[test] | |
| 507 | + fn reads_a_zip_dump_and_skips_bad_records() { | |
| 508 | + use std::io::{Cursor, Write}; | |
| 509 | + let mut buf = Cursor::new(Vec::new()); | |
| 510 | + { | |
| 511 | + let mut zip = zip::ZipWriter::new(&mut buf); | |
| 512 | + let opts = zip::write::SimpleFileOptions::default(); | |
| 513 | + zip.start_file("GHSA-a.json", opts).expect("start"); | |
| 514 | + zip.write_all( | |
| 515 | + br#"{"id":"GHSA-a","affected":[{"package":{"ecosystem":"npm","name":"left"}, | |
| 516 | + "ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"2.0.0"}]}]}]}"#, | |
| 517 | + ) | |
| 518 | + .expect("write"); | |
| 519 | + zip.start_file("broken.json", opts).expect("start"); | |
| 520 | + zip.write_all(b"{ nope").expect("write"); | |
| 521 | + zip.start_file("readme.txt", opts).expect("start"); | |
| 522 | + zip.write_all(b"ignored").expect("write"); | |
| 523 | + zip.finish().expect("finish"); | |
| 524 | + } | |
| 525 | + buf.set_position(0); | |
| 526 | + let (found, skipped) = read_dump(buf, "npm").expect("dump reads"); | |
| 527 | + assert_eq!(skipped, 1); | |
| 528 | + assert_eq!(found.len(), 1); | |
| 529 | + assert_eq!(found[0].package, "left"); | |
| 530 | + } | |
| 531 | + | |
| 532 | + #[test] | |
| 533 | + fn parses_cargo_lock_registry_packages_only() { | |
| 534 | + let text = r#" | |
| 535 | +version = 4 | |
| 536 | + | |
| 537 | +[[package]] | |
| 538 | +name = "serde" | |
| 539 | +version = "1.0.0" | |
| 540 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 541 | +checksum = "abc" | |
| 542 | + | |
| 543 | +[[package]] | |
| 544 | +name = "local" | |
| 545 | +version = "0.1.0" | |
| 546 | +"#; | |
| 547 | + let deps = parse_cargo_lock(text, "Cargo.lock").expect("parses"); | |
| 548 | + assert_eq!(deps.len(), 1); | |
| 549 | + assert_eq!(deps[0].ecosystem, "crates.io"); | |
| 550 | + assert_eq!(deps[0].package, "serde"); | |
| 551 | + assert_eq!(deps[0].version, "1.0.0"); | |
| 552 | + assert_eq!(deps[0].manifest_path, "Cargo.lock"); | |
| 553 | + } | |
| 554 | + | |
| 555 | + #[test] | |
| 556 | + fn parses_package_lock_v3_packages() { | |
| 557 | + let text = r#"{ | |
| 558 | + "lockfileVersion": 3, | |
| 559 | + "packages": { | |
| 560 | + "": {"name": "app", "version": "1.0.0"}, | |
| 561 | + "node_modules/left-pad": {"version": "1.3.0"}, | |
| 562 | + "node_modules/a/node_modules/@scope/inner": {"version": "2.0.1"}, | |
| 563 | + "node_modules/linked": {"link": true}, | |
| 564 | + "packages/ws": {"version": "0.0.1"} | |
| 565 | + } | |
| 566 | + }"#; | |
| 567 | + let mut deps = parse_package_lock(text, "web/package-lock.json").expect("parses"); | |
| 568 | + deps.sort(); | |
| 569 | + let names: Vec<(&str, &str)> = deps | |
| 570 | + .iter() | |
| 571 | + .map(|d| (d.package.as_str(), d.version.as_str())) | |
| 572 | + .collect(); | |
| 573 | + assert_eq!( | |
| 574 | + names, | |
| 575 | + vec![("@scope/inner", "2.0.1"), ("left-pad", "1.3.0")] | |
| 576 | + ); | |
| 577 | + assert!(deps.iter().all(|d| d.ecosystem == "npm")); | |
| 578 | + } | |
| 579 | + | |
| 580 | + #[test] | |
| 581 | + fn parses_go_sum_and_drops_go_mod_lines() { | |
| 582 | + let text = "\ | |
| 583 | +github.com/a/b v1.2.3 h1:abc= | |
| 584 | +github.com/a/b v1.2.3/go.mod h1:def= | |
| 585 | +github.com/c/d v0.0.0-20210101000000-abcdef123456 h1:ghi= | |
| 586 | +github.com/c/d v0.0.0-20210101000000-abcdef123456/go.mod h1:jkl= | |
| 587 | +"; | |
| 588 | + let deps = parse_go_sum(text, "go.sum"); | |
| 589 | + assert_eq!(deps.len(), 2); | |
| 590 | + assert_eq!(deps[0].ecosystem, "Go"); | |
| 591 | + assert_eq!(deps[0].package, "github.com/a/b"); | |
| 592 | + assert_eq!(deps[0].version, "v1.2.3"); | |
| 593 | + } | |
| 594 | +} |
crates/forge-core/src/audit.rs| @@ -0,0 +1,413 @@ | ||
| 1 | +//! Security audit log: the action names, the entry shape and the list filter. The stored shape is | |
| 2 | +//! in migrations/0010_audit_log.sql; the store functions live in store/audit.rs. Rows are never | |
| 3 | +//! edited; only the retention purge removes them. | |
| 4 | + | |
| 5 | +use ulid::Ulid; | |
| 6 | + | |
| 7 | +use crate::time::now_stamp; | |
| 8 | + | |
| 9 | +/// Every security-relevant action that is recorded. The `as_str` names are stored and shown. | |
| 10 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 11 | +pub enum AuditAction { | |
| 12 | + LoginSuccess, | |
| 13 | + LoginFailed, | |
| 14 | + Login2faFailed, | |
| 15 | + Logout, | |
| 16 | + PasswordChanged, | |
| 17 | + EmailChanged, | |
| 18 | + PasswordResetByAdmin, | |
| 19 | + TotpEnabled, | |
| 20 | + TotpDisabled, | |
| 21 | + RecoveryCodesRegenerated, | |
| 22 | + SshKeyAdded, | |
| 23 | + SshKeyRemoved, | |
| 24 | + GpgKeyAdded, | |
| 25 | + GpgKeyDeleted, | |
| 26 | + TokenCreated, | |
| 27 | + TokenRevoked, | |
| 28 | + SessionRevoked, | |
| 29 | + UserCreated, | |
| 30 | + UsernameChanged, | |
| 31 | + UserDeleted, | |
| 32 | + RepoCreated, | |
| 33 | + RepoDeleted, | |
| 34 | + RepoRenamed, | |
| 35 | + RepoTransferred, | |
| 36 | + RepoTransferInitiated, | |
| 37 | + RepoTransferAccepted, | |
| 38 | + RepoTransferDeclined, | |
| 39 | + RepoTransferCancelled, | |
| 40 | + RepoVisibilityChanged, | |
| 41 | + CollaboratorAdded, | |
| 42 | + CollaboratorRemoved, | |
| 43 | + CollaboratorRoleChanged, | |
| 44 | + BranchRuleCreated, | |
| 45 | + BranchRuleUpdated, | |
| 46 | + BranchRuleDeleted, | |
| 47 | + RepoArchived, | |
| 48 | + RepoUnarchived, | |
| 49 | + WebhookCreated, | |
| 50 | + WebhookUpdated, | |
| 51 | + WebhookDeleted, | |
| 52 | + MirrorCreated, | |
| 53 | + MirrorUpdated, | |
| 54 | + MirrorDeleted, | |
| 55 | + MirrorSyncFailed, | |
| 56 | + BackupStarted, | |
| 57 | + FsckStarted, | |
| 58 | + ReplicationReconcileStarted, | |
| 59 | + CiSecretSet, | |
| 60 | + CiSecretDeleted, | |
| 61 | + CiRunnerRegistered, | |
| 62 | + CiRunnerDeleted, | |
| 63 | + CiRegistrationTokenCreated, | |
| 64 | + AdvisoryAlertDismissed, | |
| 65 | + AdvisoryAlertReopened, | |
| 66 | + UiPrefsChanged, | |
| 67 | + DiscussionsEnabled, | |
| 68 | + DiscussionsDisabled, | |
| 69 | + DiscussionCreated, | |
| 70 | + DiscussionEdited, | |
| 71 | + DiscussionDeleted, | |
| 72 | + DiscussionLocked, | |
| 73 | + DiscussionUnlocked, | |
| 74 | + DiscussionAnswered, | |
| 75 | + DiscussionCommentCreated, | |
| 76 | + DiscussionCommentEdited, | |
| 77 | + DiscussionCommentDeleted, | |
| 78 | + MailSettingsUpdated, | |
| 79 | + MailTestQueued, | |
| 80 | + SnippetCreated, | |
| 81 | + SnippetUpdated, | |
| 82 | + SnippetDeleted, | |
| 83 | + WikiPageSaved, | |
| 84 | + WikiEnabled, | |
| 85 | + WikiDisabled, | |
| 86 | + WikiPushed, | |
| 87 | + OrgCreated, | |
| 88 | + OrgDeleted, | |
| 89 | + OrgSettingsUpdated, | |
| 90 | + OrgMemberAdded, | |
| 91 | + OrgMemberRemoved, | |
| 92 | + OrgMemberRoleChanged, | |
| 93 | + TeamCreated, | |
| 94 | + TeamUpdated, | |
| 95 | + TeamDeleted, | |
| 96 | + TeamMemberAdded, | |
| 97 | + TeamMemberRemoved, | |
| 98 | + TeamRepoAdded, | |
| 99 | + TeamRepoRemoved, | |
| 100 | + PasswordResetCompleted, | |
| 101 | + EmailVerified, | |
| 102 | + InviteSent, | |
| 103 | + InviteAccepted, | |
| 104 | +} | |
| 105 | + | |
| 106 | +impl AuditAction { | |
| 107 | + /// Every action, in declaration order. Used to parse filter values. | |
| 108 | + pub const ALL: &'static [AuditAction] = &[ | |
| 109 | + AuditAction::LoginSuccess, | |
| 110 | + AuditAction::LoginFailed, | |
| 111 | + AuditAction::Login2faFailed, | |
| 112 | + AuditAction::Logout, | |
| 113 | + AuditAction::PasswordChanged, | |
| 114 | + AuditAction::EmailChanged, | |
| 115 | + AuditAction::PasswordResetByAdmin, | |
| 116 | + AuditAction::TotpEnabled, | |
| 117 | + AuditAction::TotpDisabled, | |
| 118 | + AuditAction::RecoveryCodesRegenerated, | |
| 119 | + AuditAction::SshKeyAdded, | |
| 120 | + AuditAction::SshKeyRemoved, | |
| 121 | + AuditAction::GpgKeyAdded, | |
| 122 | + AuditAction::GpgKeyDeleted, | |
| 123 | + AuditAction::TokenCreated, | |
| 124 | + AuditAction::TokenRevoked, | |
| 125 | + AuditAction::SessionRevoked, | |
| 126 | + AuditAction::UserCreated, | |
| 127 | + AuditAction::UsernameChanged, | |
| 128 | + AuditAction::UserDeleted, | |
| 129 | + AuditAction::RepoCreated, | |
| 130 | + AuditAction::RepoDeleted, | |
| 131 | + AuditAction::RepoRenamed, | |
| 132 | + AuditAction::RepoTransferred, | |
| 133 | + AuditAction::RepoTransferInitiated, | |
| 134 | + AuditAction::RepoTransferAccepted, | |
| 135 | + AuditAction::RepoTransferDeclined, | |
| 136 | + AuditAction::RepoTransferCancelled, | |
| 137 | + AuditAction::RepoVisibilityChanged, | |
| 138 | + AuditAction::CollaboratorAdded, | |
| 139 | + AuditAction::CollaboratorRemoved, | |
| 140 | + AuditAction::CollaboratorRoleChanged, | |
| 141 | + AuditAction::BranchRuleCreated, | |
| 142 | + AuditAction::BranchRuleUpdated, | |
| 143 | + AuditAction::BranchRuleDeleted, | |
| 144 | + AuditAction::RepoArchived, | |
| 145 | + AuditAction::RepoUnarchived, | |
| 146 | + AuditAction::WebhookCreated, | |
| 147 | + AuditAction::WebhookUpdated, | |
| 148 | + AuditAction::WebhookDeleted, | |
| 149 | + AuditAction::MirrorCreated, | |
| 150 | + AuditAction::MirrorUpdated, | |
| 151 | + AuditAction::MirrorDeleted, | |
| 152 | + AuditAction::MirrorSyncFailed, | |
| 153 | + AuditAction::BackupStarted, | |
| 154 | + AuditAction::FsckStarted, | |
| 155 | + AuditAction::ReplicationReconcileStarted, | |
| 156 | + AuditAction::CiSecretSet, | |
| 157 | + AuditAction::CiSecretDeleted, | |
| 158 | + AuditAction::CiRunnerRegistered, | |
| 159 | + AuditAction::CiRunnerDeleted, | |
| 160 | + AuditAction::CiRegistrationTokenCreated, | |
| 161 | + AuditAction::AdvisoryAlertDismissed, | |
| 162 | + AuditAction::AdvisoryAlertReopened, | |
| 163 | + AuditAction::UiPrefsChanged, | |
| 164 | + AuditAction::DiscussionsEnabled, | |
| 165 | + AuditAction::DiscussionsDisabled, | |
| 166 | + AuditAction::DiscussionCreated, | |
| 167 | + AuditAction::DiscussionEdited, | |
| 168 | + AuditAction::DiscussionDeleted, | |
| 169 | + AuditAction::DiscussionLocked, | |
| 170 | + AuditAction::DiscussionUnlocked, | |
| 171 | + AuditAction::DiscussionAnswered, | |
| 172 | + AuditAction::DiscussionCommentCreated, | |
| 173 | + AuditAction::DiscussionCommentEdited, | |
| 174 | + AuditAction::DiscussionCommentDeleted, | |
| 175 | + AuditAction::MailSettingsUpdated, | |
| 176 | + AuditAction::MailTestQueued, | |
| 177 | + AuditAction::SnippetCreated, | |
| 178 | + AuditAction::SnippetUpdated, | |
| 179 | + AuditAction::SnippetDeleted, | |
| 180 | + AuditAction::WikiPageSaved, | |
| 181 | + AuditAction::WikiEnabled, | |
| 182 | + AuditAction::WikiDisabled, | |
| 183 | + AuditAction::WikiPushed, | |
| 184 | + AuditAction::OrgCreated, | |
| 185 | + AuditAction::OrgDeleted, | |
| 186 | + AuditAction::OrgSettingsUpdated, | |
| 187 | + AuditAction::OrgMemberAdded, | |
| 188 | + AuditAction::OrgMemberRemoved, | |
| 189 | + AuditAction::OrgMemberRoleChanged, | |
| 190 | + AuditAction::TeamCreated, | |
| 191 | + AuditAction::TeamUpdated, | |
| 192 | + AuditAction::TeamDeleted, | |
| 193 | + AuditAction::TeamMemberAdded, | |
| 194 | + AuditAction::TeamMemberRemoved, | |
| 195 | + AuditAction::TeamRepoAdded, | |
| 196 | + AuditAction::TeamRepoRemoved, | |
| 197 | + AuditAction::PasswordResetCompleted, | |
| 198 | + AuditAction::EmailVerified, | |
| 199 | + AuditAction::InviteSent, | |
| 200 | + AuditAction::InviteAccepted, | |
| 201 | + ]; | |
| 202 | + | |
| 203 | + pub fn as_str(self) -> &'static str { | |
| 204 | + match self { | |
| 205 | + AuditAction::LoginSuccess => "login_success", | |
| 206 | + AuditAction::LoginFailed => "login_failed", | |
| 207 | + AuditAction::Login2faFailed => "login_2fa_failed", | |
| 208 | + AuditAction::Logout => "logout", | |
| 209 | + AuditAction::PasswordChanged => "password_changed", | |
| 210 | + AuditAction::EmailChanged => "email_changed", | |
| 211 | + AuditAction::PasswordResetByAdmin => "password_reset_by_admin", | |
| 212 | + AuditAction::TotpEnabled => "totp_enabled", | |
| 213 | + AuditAction::TotpDisabled => "totp_disabled", | |
| 214 | + AuditAction::RecoveryCodesRegenerated => "recovery_codes_regenerated", | |
| 215 | + AuditAction::SshKeyAdded => "ssh_key_added", | |
| 216 | + AuditAction::SshKeyRemoved => "ssh_key_removed", | |
| 217 | + AuditAction::GpgKeyAdded => "gpg_key_added", | |
| 218 | + AuditAction::GpgKeyDeleted => "gpg_key_deleted", | |
| 219 | + AuditAction::TokenCreated => "token_created", | |
| 220 | + AuditAction::TokenRevoked => "token_revoked", | |
| 221 | + AuditAction::SessionRevoked => "session_revoked", | |
| 222 | + AuditAction::UserCreated => "user_created", | |
| 223 | + AuditAction::UsernameChanged => "username_changed", | |
| 224 | + AuditAction::UserDeleted => "user_deleted", | |
| 225 | + AuditAction::RepoCreated => "repo_created", | |
| 226 | + AuditAction::RepoDeleted => "repo_deleted", | |
| 227 | + AuditAction::RepoRenamed => "repo_renamed", | |
| 228 | + AuditAction::RepoTransferred => "repo_transferred", | |
| 229 | + AuditAction::RepoTransferInitiated => "repo_transfer_initiated", | |
| 230 | + AuditAction::RepoTransferAccepted => "repo_transfer_accepted", | |
| 231 | + AuditAction::RepoTransferDeclined => "repo_transfer_declined", | |
| 232 | + AuditAction::RepoTransferCancelled => "repo_transfer_cancelled", | |
| 233 | + AuditAction::RepoVisibilityChanged => "repo_visibility_changed", | |
| 234 | + AuditAction::CollaboratorAdded => "collaborator_added", | |
| 235 | + AuditAction::CollaboratorRemoved => "collaborator_removed", | |
| 236 | + AuditAction::CollaboratorRoleChanged => "collaborator_role_changed", | |
| 237 | + AuditAction::BranchRuleCreated => "branch_rule_created", | |
| 238 | + AuditAction::BranchRuleUpdated => "branch_rule_updated", | |
| 239 | + AuditAction::BranchRuleDeleted => "branch_rule_deleted", | |
| 240 | + AuditAction::RepoArchived => "repo_archived", | |
| 241 | + AuditAction::RepoUnarchived => "repo_unarchived", | |
| 242 | + AuditAction::WebhookCreated => "webhook_created", | |
| 243 | + AuditAction::WebhookUpdated => "webhook_updated", | |
| 244 | + AuditAction::WebhookDeleted => "webhook_deleted", | |
| 245 | + AuditAction::MirrorCreated => "mirror_created", | |
| 246 | + AuditAction::MirrorUpdated => "mirror_updated", | |
| 247 | + AuditAction::MirrorDeleted => "mirror_deleted", | |
| 248 | + AuditAction::MirrorSyncFailed => "mirror_sync_failed", | |
| 249 | + AuditAction::BackupStarted => "backup_started", | |
| 250 | + AuditAction::FsckStarted => "fsck_started", | |
| 251 | + AuditAction::ReplicationReconcileStarted => "replication_reconcile_started", | |
| 252 | + AuditAction::CiSecretSet => "ci_secret_set", | |
| 253 | + AuditAction::CiSecretDeleted => "ci_secret_deleted", | |
| 254 | + AuditAction::CiRunnerRegistered => "ci_runner_registered", | |
| 255 | + AuditAction::CiRunnerDeleted => "ci_runner_deleted", | |
| 256 | + AuditAction::CiRegistrationTokenCreated => "ci_registration_token_created", | |
| 257 | + AuditAction::AdvisoryAlertDismissed => "advisory_alert_dismissed", | |
| 258 | + AuditAction::AdvisoryAlertReopened => "advisory_alert_reopened", | |
| 259 | + AuditAction::UiPrefsChanged => "ui_prefs_changed", | |
| 260 | + AuditAction::DiscussionsEnabled => "discussions_enabled", | |
| 261 | + AuditAction::DiscussionsDisabled => "discussions_disabled", | |
| 262 | + AuditAction::DiscussionCreated => "discussion_created", | |
| 263 | + AuditAction::DiscussionEdited => "discussion_edited", | |
| 264 | + AuditAction::DiscussionDeleted => "discussion_deleted", | |
| 265 | + AuditAction::DiscussionLocked => "discussion_locked", | |
| 266 | + AuditAction::DiscussionUnlocked => "discussion_unlocked", | |
| 267 | + AuditAction::DiscussionAnswered => "discussion_answered", | |
| 268 | + AuditAction::DiscussionCommentCreated => "discussion_comment_created", | |
| 269 | + AuditAction::DiscussionCommentEdited => "discussion_comment_edited", | |
| 270 | + AuditAction::DiscussionCommentDeleted => "discussion_comment_deleted", | |
| 271 | + AuditAction::MailSettingsUpdated => "mail_settings_updated", | |
| 272 | + AuditAction::MailTestQueued => "mail_test_queued", | |
| 273 | + AuditAction::SnippetCreated => "snippet_created", | |
| 274 | + AuditAction::SnippetUpdated => "snippet_updated", | |
| 275 | + AuditAction::SnippetDeleted => "snippet_deleted", | |
| 276 | + AuditAction::WikiPageSaved => "wiki_page_saved", | |
| 277 | + AuditAction::WikiEnabled => "wiki_enabled", | |
| 278 | + AuditAction::WikiDisabled => "wiki_disabled", | |
| 279 | + AuditAction::WikiPushed => "wiki_pushed", | |
| 280 | + AuditAction::OrgCreated => "org_created", | |
| 281 | + AuditAction::OrgDeleted => "org_deleted", | |
| 282 | + AuditAction::OrgSettingsUpdated => "org_settings_updated", | |
| 283 | + AuditAction::OrgMemberAdded => "org_member_added", | |
| 284 | + AuditAction::OrgMemberRemoved => "org_member_removed", | |
| 285 | + AuditAction::OrgMemberRoleChanged => "org_member_role_changed", | |
| 286 | + AuditAction::TeamCreated => "team_created", | |
| 287 | + AuditAction::TeamUpdated => "team_updated", | |
| 288 | + AuditAction::TeamDeleted => "team_deleted", | |
| 289 | + AuditAction::TeamMemberAdded => "team_member_added", | |
| 290 | + AuditAction::TeamMemberRemoved => "team_member_removed", | |
| 291 | + AuditAction::TeamRepoAdded => "team_repo_added", | |
| 292 | + AuditAction::TeamRepoRemoved => "team_repo_removed", | |
| 293 | + AuditAction::PasswordResetCompleted => "password_reset_completed", | |
| 294 | + AuditAction::EmailVerified => "email_verified", | |
| 295 | + AuditAction::InviteSent => "invite_sent", | |
| 296 | + AuditAction::InviteAccepted => "invite_accepted", | |
| 297 | + } | |
| 298 | + } | |
| 299 | + | |
| 300 | + /// The action with this stored name, if any. | |
| 301 | + pub fn parse(name: &str) -> Option<AuditAction> { | |
| 302 | + AuditAction::ALL | |
| 303 | + .iter() | |
| 304 | + .copied() | |
| 305 | + .find(|action| action.as_str() == name) | |
| 306 | + } | |
| 307 | +} | |
| 308 | + | |
| 309 | +/// One row of the audit log. `at` is an RFC 3339 UTC stamp. `data` is a JSON object as text. | |
| 310 | +#[derive(Debug, Clone, PartialEq, Eq)] | |
| 311 | +pub struct AuditEntry { | |
| 312 | + pub id: String, | |
| 313 | + pub at: String, | |
| 314 | + pub actor_id: Option<String>, | |
| 315 | + pub actor_name: String, | |
| 316 | + pub action: AuditAction, | |
| 317 | + pub target_kind: String, | |
| 318 | + pub target_id: String, | |
| 319 | + pub target_name: String, | |
| 320 | + pub ip: String, | |
| 321 | + pub data: String, | |
| 322 | +} | |
| 323 | + | |
| 324 | +impl AuditEntry { | |
| 325 | + /// A new entry stamped with the current time and a fresh id. Everything else is empty. | |
| 326 | + pub fn new(action: AuditAction) -> AuditEntry { | |
| 327 | + AuditEntry { | |
| 328 | + id: Ulid::generate().to_string(), | |
| 329 | + at: now_stamp(), | |
| 330 | + actor_id: None, | |
| 331 | + actor_name: String::new(), | |
| 332 | + action, | |
| 333 | + target_kind: String::new(), | |
| 334 | + target_id: String::new(), | |
| 335 | + target_name: String::new(), | |
| 336 | + ip: String::new(), | |
| 337 | + data: "{}".to_string(), | |
| 338 | + } | |
| 339 | + } | |
| 340 | +} | |
| 341 | + | |
| 342 | +/// Optional filters for [`crate::store::Store::list_audit`]. Unset fields do not restrict. | |
| 343 | +#[derive(Debug, Clone, Default, PartialEq, Eq)] | |
| 344 | +pub struct AuditFilter { | |
| 345 | + pub actor_id: Option<String>, | |
| 346 | + pub action: Option<AuditAction>, | |
| 347 | + pub target_kind: Option<String>, | |
| 348 | + pub target_id: Option<String>, | |
| 349 | + /// Matches entries where this user is the actor, or the `user` target. | |
| 350 | + pub involving_user: Option<String>, | |
| 351 | +} | |
| 352 | + | |
| 353 | +/// Position in the newest-first listing: the last entry of the previous page. | |
| 354 | +#[derive(Debug, Clone, PartialEq, Eq)] | |
| 355 | +pub struct AuditCursor { | |
| 356 | + pub at: String, | |
| 357 | + pub id: String, | |
| 358 | +} | |
| 359 | + | |
| 360 | +impl AuditCursor { | |
| 361 | + /// The cursor for the entry a page ended on. | |
| 362 | + pub fn after(entry: &AuditEntry) -> AuditCursor { | |
| 363 | + AuditCursor { | |
| 364 | + at: entry.at.clone(), | |
| 365 | + id: entry.id.clone(), | |
| 366 | + } | |
| 367 | + } | |
| 368 | + | |
| 369 | + /// Text form for a URL: `<at>_<id>`. Neither part contains `_`. | |
| 370 | + pub fn encode(&self) -> String { | |
| 371 | + format!("{}_{}", self.at, self.id) | |
| 372 | + } | |
| 373 | + | |
| 374 | + /// Parses [`AuditCursor::encode`] output. Anything malformed is `None`. | |
| 375 | + pub fn decode(text: &str) -> Option<AuditCursor> { | |
| 376 | + let (at, id) = text.split_once('_')?; | |
| 377 | + let stamp_ok = at.len() == 30 && at.ends_with('Z') && at.contains('T'); | |
| 378 | + let id_ok = !id.is_empty() && id.bytes().all(|b| b.is_ascii_alphanumeric()); | |
| 379 | + (stamp_ok && id_ok).then(|| AuditCursor { | |
| 380 | + at: at.to_string(), | |
| 381 | + id: id.to_string(), | |
| 382 | + }) | |
| 383 | + } | |
| 384 | +} | |
| 385 | + | |
| 386 | +#[cfg(test)] | |
| 387 | +mod tests { | |
| 388 | + use super::*; | |
| 389 | + | |
| 390 | + #[test] | |
| 391 | + fn names_are_unique_and_round_trip() { | |
| 392 | + let mut names: Vec<&str> = AuditAction::ALL.iter().map(|a| a.as_str()).collect(); | |
| 393 | + assert_eq!(names.len(), 92); | |
| 394 | + for action in AuditAction::ALL { | |
| 395 | + assert_eq!(AuditAction::parse(action.as_str()), Some(*action)); | |
| 396 | + } | |
| 397 | + names.sort(); | |
| 398 | + names.dedup(); | |
| 399 | + assert_eq!(names.len(), 92); | |
| 400 | + assert_eq!(AuditAction::parse("nope"), None); | |
| 401 | + } | |
| 402 | + | |
| 403 | + #[test] | |
| 404 | + fn cursor_round_trips_and_rejects_junk() { | |
| 405 | + let entry = AuditEntry::new(AuditAction::Logout); | |
| 406 | + let cursor = AuditCursor::after(&entry); | |
| 407 | + assert_eq!(AuditCursor::decode(&cursor.encode()), Some(cursor)); | |
| 408 | + assert_eq!(AuditCursor::decode(""), None); | |
| 409 | + assert_eq!(AuditCursor::decode("nounderscore"), None); | |
| 410 | + assert_eq!(AuditCursor::decode("2026-10-15T00:00:00Z_"), None); | |
| 411 | + assert_eq!(AuditCursor::decode("bogus_01ABC"), None); | |
| 412 | + } | |
| 413 | +} |
crates/forge-core/src/authz.rs| @@ -0,0 +1,569 @@ | ||
| 1 | +//! Authorization rules. Pure, no IO. | |
| 2 | + | |
| 3 | +use crate::model::{Repo, Role, User, Visibility}; | |
| 4 | + | |
| 5 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 6 | +pub enum Action { | |
| 7 | + Read, | |
| 8 | + /// Edit anyone's issue or comment, close/reopen, labels, assignees, milestones. | |
| 9 | + /// Not blocked by `archived` (the pages handle archived repos). | |
| 10 | + Triage, | |
| 11 | + Write, | |
| 12 | + Admin, | |
| 13 | + /// Delete the repo. Owner and instance admin only; a collaborator role never grants it. | |
| 14 | + Delete, | |
| 15 | +} | |
| 16 | + | |
| 17 | +/// `NotFound` when the actor cannot even see the repo; `Forbidden` when they can see it but lack the right. | |
| 18 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 19 | +pub enum Denied { | |
| 20 | + NotFound, | |
| 21 | + Forbidden, | |
| 22 | +} | |
| 23 | + | |
| 24 | +/// The higher of two optional roles. `Read < Triage < Write < Maintain < Admin`. | |
| 25 | +pub fn max_role(a: Option<Role>, b: Option<Role>) -> Option<Role> { | |
| 26 | + fn rank(role: Role) -> u8 { | |
| 27 | + match role { | |
| 28 | + Role::Read => 1, | |
| 29 | + Role::Triage => 2, | |
| 30 | + Role::Write => 3, | |
| 31 | + Role::Maintain => 4, | |
| 32 | + Role::Admin => 5, | |
| 33 | + } | |
| 34 | + } | |
| 35 | + match (a, b) { | |
| 36 | + (Some(x), Some(y)) => Some(if rank(y) > rank(x) { y } else { x }), | |
| 37 | + (x, None) => x, | |
| 38 | + (None, y) => y, | |
| 39 | + } | |
| 40 | +} | |
| 41 | + | |
| 42 | +/// Owner or instance admin: all actions. Public repo: Read for anyone. | |
| 43 | +/// Private repo with no role: NotFound. Read/Triage: Read. Write/Maintain: Read+Write. Admin: all. | |
| 44 | +/// Triage is granted to Triage, Write, Maintain and Admin. | |
| 45 | +/// Archived repos deny Write with Forbidden. | |
| 46 | +/// | |
| 47 | +/// `org_role` is the role the actor gets through an organization that owns the repo (see | |
| 48 | +/// `Store::org_repo_role`). The effective role is `max_role(collaborator_role, org_role)`. A | |
| 49 | +/// collaborator Admin never deletes a repo; an org-derived Admin (org owner, or a team with | |
| 50 | +/// the admin role) does, because org-owned repos have no single owner person. | |
| 51 | +pub fn authorize( | |
| 52 | + actor: Option<&User>, | |
| 53 | + repo: &Repo, | |
| 54 | + collaborator_role: Option<Role>, | |
| 55 | + org_role: Option<Role>, | |
| 56 | + action: Action, | |
| 57 | +) -> Result<(), Denied> { | |
| 58 | + // A role only belongs to a signed-in actor. | |
| 59 | + let role = if actor.is_some() { | |
| 60 | + max_role(collaborator_role, org_role) | |
| 61 | + } else { | |
| 62 | + None | |
| 63 | + }; | |
| 64 | + | |
| 65 | + if action == Action::Write && repo.archived { | |
| 66 | + return Err(Denied::Forbidden); | |
| 67 | + } | |
| 68 | + | |
| 69 | + let privileged = actor.is_some_and(|u| u.is_admin || u.id == repo.owner_id); | |
| 70 | + if privileged { | |
| 71 | + return Ok(()); | |
| 72 | + } | |
| 73 | + | |
| 74 | + let can_see = repo.visibility == Visibility::Public || role.is_some(); | |
| 75 | + if !can_see { | |
| 76 | + return Err(Denied::NotFound); | |
| 77 | + } | |
| 78 | + | |
| 79 | + let granted = match action { | |
| 80 | + Action::Read => true, | |
| 81 | + Action::Triage => matches!( | |
| 82 | + role, | |
| 83 | + Some(Role::Triage | Role::Write | Role::Maintain | Role::Admin) | |
| 84 | + ), | |
| 85 | + Action::Write => matches!(role, Some(Role::Write | Role::Maintain | Role::Admin)), | |
| 86 | + Action::Admin => matches!(role, Some(Role::Admin)), | |
| 87 | + Action::Delete => actor.is_some() && org_role == Some(Role::Admin), | |
| 88 | + }; | |
| 89 | + if granted { | |
| 90 | + Ok(()) | |
| 91 | + } else { | |
| 92 | + Err(Denied::Forbidden) | |
| 93 | + } | |
| 94 | +} | |
| 95 | + | |
| 96 | +#[cfg(test)] | |
| 97 | +mod tests { | |
| 98 | + use super::*; | |
| 99 | + use crate::model::AccountKind; | |
| 100 | + | |
| 101 | + const ACTIONS: [Action; 5] = [ | |
| 102 | + Action::Read, | |
| 103 | + Action::Triage, | |
| 104 | + Action::Write, | |
| 105 | + Action::Admin, | |
| 106 | + Action::Delete, | |
| 107 | + ]; | |
| 108 | + const ROLES: [Option<Role>; 6] = [ | |
| 109 | + None, | |
| 110 | + Some(Role::Read), | |
| 111 | + Some(Role::Triage), | |
| 112 | + Some(Role::Write), | |
| 113 | + Some(Role::Maintain), | |
| 114 | + Some(Role::Admin), | |
| 115 | + ]; | |
| 116 | + | |
| 117 | + fn user(id: &str, is_admin: bool) -> User { | |
| 118 | + User { | |
| 119 | + id: id.into(), | |
| 120 | + username: id.into(), | |
| 121 | + email: None, | |
| 122 | + password_hash: String::new(), | |
| 123 | + is_admin, | |
| 124 | + created_at: String::new(), | |
| 125 | + about: String::new(), | |
| 126 | + timezone: "UTC".into(), | |
| 127 | + avatar_hash: None, | |
| 128 | + theme: "auto".into(), | |
| 129 | + language: "auto".into(), | |
| 130 | + kind: AccountKind::User, | |
| 131 | + email_notifications: true, | |
| 132 | + } | |
| 133 | + } | |
| 134 | + | |
| 135 | + fn repo(visibility: Visibility, archived: bool) -> Repo { | |
| 136 | + Repo { | |
| 137 | + id: "repo1".into(), | |
| 138 | + owner_id: "owner".into(), | |
| 139 | + owner_name: "owner".into(), | |
| 140 | + name: "r".into(), | |
| 141 | + description: String::new(), | |
| 142 | + visibility, | |
| 143 | + default_branch: "main".into(), | |
| 144 | + archived, | |
| 145 | + created_at: String::new(), | |
| 146 | + fork_of: None, | |
| 147 | + } | |
| 148 | + } | |
| 149 | + | |
| 150 | + /// Expected result from the contract, written independently of the implementation. | |
| 151 | + fn expected( | |
| 152 | + actor: Option<&User>, | |
| 153 | + r: &Repo, | |
| 154 | + role: Option<Role>, | |
| 155 | + action: Action, | |
| 156 | + ) -> Result<(), Denied> { | |
| 157 | + let role = if actor.is_some() { role } else { None }; | |
| 158 | + if action == Action::Write && r.archived { | |
| 159 | + return Err(Denied::Forbidden); | |
| 160 | + } | |
| 161 | + if actor.is_some_and(|u| u.is_admin || u.id == r.owner_id) { | |
| 162 | + return Ok(()); | |
| 163 | + } | |
| 164 | + if action == Action::Delete { | |
| 165 | + // Only owner or instance admin: hidden repos are 404, visible ones 403. | |
| 166 | + return if r.visibility == Visibility::Public || role.is_some() { | |
| 167 | + Err(Denied::Forbidden) | |
| 168 | + } else { | |
| 169 | + Err(Denied::NotFound) | |
| 170 | + }; | |
| 171 | + } | |
| 172 | + if r.visibility == Visibility::Public { | |
| 173 | + let ok = match action { | |
| 174 | + Action::Read => true, | |
| 175 | + Action::Triage => matches!( | |
| 176 | + role, | |
| 177 | + Some(Role::Triage | Role::Write | Role::Maintain | Role::Admin) | |
| 178 | + ), | |
| 179 | + Action::Write => matches!(role, Some(Role::Write | Role::Maintain | Role::Admin)), | |
| 180 | + Action::Admin => role == Some(Role::Admin), | |
| 181 | + Action::Delete => false, | |
| 182 | + }; | |
| 183 | + return if ok { Ok(()) } else { Err(Denied::Forbidden) }; | |
| 184 | + } | |
| 185 | + match role { | |
| 186 | + None => Err(Denied::NotFound), | |
| 187 | + Some(r) => { | |
| 188 | + let ok = match action { | |
| 189 | + Action::Read => true, | |
| 190 | + Action::Triage => { | |
| 191 | + matches!(r, Role::Triage | Role::Write | Role::Maintain | Role::Admin) | |
| 192 | + } | |
| 193 | + Action::Write => matches!(r, Role::Write | Role::Maintain | Role::Admin), | |
| 194 | + Action::Admin => r == Role::Admin, | |
| 195 | + Action::Delete => false, | |
| 196 | + }; | |
| 197 | + if ok { Ok(()) } else { Err(Denied::Forbidden) } | |
| 198 | + } | |
| 199 | + } | |
| 200 | + } | |
| 201 | + | |
| 202 | + #[test] | |
| 203 | + fn exhaustive_matrix_matches_contract() { | |
| 204 | + let stranger = user("stranger", false); | |
| 205 | + let owner = user("owner", false); | |
| 206 | + let admin = user("root", true); | |
| 207 | + let actors: [Option<&User>; 4] = [None, Some(&stranger), Some(&owner), Some(&admin)]; | |
| 208 | + for visibility in [Visibility::Public, Visibility::Private] { | |
| 209 | + for archived in [false, true] { | |
| 210 | + let r = repo(visibility, archived); | |
| 211 | + for actor in actors { | |
| 212 | + for role in ROLES { | |
| 213 | + for action in ACTIONS { | |
| 214 | + assert_eq!( | |
| 215 | + authorize(actor, &r, role, None, action), | |
| 216 | + expected(actor, &r, role, action), | |
| 217 | + "vis={visibility:?} archived={archived} actor={:?} role={role:?} action={action:?}", | |
| 218 | + actor.map(|u| u.id.as_str()), | |
| 219 | + ); | |
| 220 | + } | |
| 221 | + } | |
| 222 | + } | |
| 223 | + } | |
| 224 | + } | |
| 225 | + } | |
| 226 | + | |
| 227 | + #[test] | |
| 228 | + fn anonymous_on_public_repo_can_read_only() { | |
| 229 | + let r = repo(Visibility::Public, false); | |
| 230 | + assert_eq!(authorize(None, &r, None, None, Action::Read), Ok(())); | |
| 231 | + assert_eq!( | |
| 232 | + authorize(None, &r, None, None, Action::Write), | |
| 233 | + Err(Denied::Forbidden) | |
| 234 | + ); | |
| 235 | + assert_eq!( | |
| 236 | + authorize(None, &r, None, None, Action::Admin), | |
| 237 | + Err(Denied::Forbidden) | |
| 238 | + ); | |
| 239 | + } | |
| 240 | + | |
| 241 | + #[test] | |
| 242 | + fn anonymous_on_private_repo_gets_not_found() { | |
| 243 | + let r = repo(Visibility::Private, false); | |
| 244 | + for action in ACTIONS { | |
| 245 | + assert_eq!( | |
| 246 | + authorize(None, &r, None, None, action), | |
| 247 | + Err(Denied::NotFound) | |
| 248 | + ); | |
| 249 | + } | |
| 250 | + } | |
| 251 | + | |
| 252 | + #[test] | |
| 253 | + fn owner_and_instance_admin_can_do_everything() { | |
| 254 | + for visibility in [Visibility::Public, Visibility::Private] { | |
| 255 | + let r = repo(visibility, false); | |
| 256 | + let owner = user("owner", false); | |
| 257 | + let admin = user("root", true); | |
| 258 | + for action in ACTIONS { | |
| 259 | + assert_eq!(authorize(Some(&owner), &r, None, None, action), Ok(())); | |
| 260 | + assert_eq!(authorize(Some(&admin), &r, None, None, action), Ok(())); | |
| 261 | + } | |
| 262 | + } | |
| 263 | + } | |
| 264 | + | |
| 265 | + #[test] | |
| 266 | + fn each_role_on_private_repo() { | |
| 267 | + let r = repo(Visibility::Private, false); | |
| 268 | + let u = user("collab", false); | |
| 269 | + let cases = [ | |
| 270 | + ( | |
| 271 | + Role::Read, | |
| 272 | + [ | |
| 273 | + Ok(()), | |
| 274 | + Err(Denied::Forbidden), | |
| 275 | + Err(Denied::Forbidden), | |
| 276 | + Err(Denied::Forbidden), | |
| 277 | + Err(Denied::Forbidden), | |
| 278 | + ], | |
| 279 | + ), | |
| 280 | + ( | |
| 281 | + Role::Triage, | |
| 282 | + [ | |
| 283 | + Ok(()), | |
| 284 | + Ok(()), | |
| 285 | + Err(Denied::Forbidden), | |
| 286 | + Err(Denied::Forbidden), | |
| 287 | + Err(Denied::Forbidden), | |
| 288 | + ], | |
| 289 | + ), | |
| 290 | + ( | |
| 291 | + Role::Write, | |
| 292 | + [ | |
| 293 | + Ok(()), | |
| 294 | + Ok(()), | |
| 295 | + Ok(()), | |
| 296 | + Err(Denied::Forbidden), | |
| 297 | + Err(Denied::Forbidden), | |
| 298 | + ], | |
| 299 | + ), | |
| 300 | + ( | |
| 301 | + Role::Maintain, | |
| 302 | + [ | |
| 303 | + Ok(()), | |
| 304 | + Ok(()), | |
| 305 | + Ok(()), | |
| 306 | + Err(Denied::Forbidden), | |
| 307 | + Err(Denied::Forbidden), | |
| 308 | + ], | |
| 309 | + ), | |
| 310 | + ( | |
| 311 | + Role::Admin, | |
| 312 | + [Ok(()), Ok(()), Ok(()), Ok(()), Err(Denied::Forbidden)], | |
| 313 | + ), | |
| 314 | + ]; | |
| 315 | + for (role, results) in cases { | |
| 316 | + for (action, want) in ACTIONS.into_iter().zip(results) { | |
| 317 | + assert_eq!( | |
| 318 | + authorize(Some(&u), &r, Some(role), None, action), | |
| 319 | + want, | |
| 320 | + "role {role:?} {action:?}" | |
| 321 | + ); | |
| 322 | + } | |
| 323 | + } | |
| 324 | + } | |
| 325 | + | |
| 326 | + #[test] | |
| 327 | + fn each_role_on_public_repo_keeps_read() { | |
| 328 | + let r = repo(Visibility::Public, false); | |
| 329 | + let u = user("collab", false); | |
| 330 | + assert_eq!(authorize(Some(&u), &r, None, None, Action::Read), Ok(())); | |
| 331 | + assert_eq!( | |
| 332 | + authorize(Some(&u), &r, None, None, Action::Write), | |
| 333 | + Err(Denied::Forbidden) | |
| 334 | + ); | |
| 335 | + assert_eq!( | |
| 336 | + authorize(Some(&u), &r, Some(Role::Read), None, Action::Read), | |
| 337 | + Ok(()) | |
| 338 | + ); | |
| 339 | + assert_eq!( | |
| 340 | + authorize(Some(&u), &r, Some(Role::Maintain), None, Action::Write), | |
| 341 | + Ok(()) | |
| 342 | + ); | |
| 343 | + assert_eq!( | |
| 344 | + authorize(Some(&u), &r, Some(Role::Maintain), None, Action::Admin), | |
| 345 | + Err(Denied::Forbidden) | |
| 346 | + ); | |
| 347 | + assert_eq!( | |
| 348 | + authorize(Some(&u), &r, Some(Role::Admin), None, Action::Admin), | |
| 349 | + Ok(()) | |
| 350 | + ); | |
| 351 | + } | |
| 352 | + | |
| 353 | + #[test] | |
| 354 | + fn stranger_on_private_repo_gets_not_found_not_forbidden() { | |
| 355 | + let r = repo(Visibility::Private, false); | |
| 356 | + let u = user("stranger", false); | |
| 357 | + for action in ACTIONS { | |
| 358 | + assert_eq!( | |
| 359 | + authorize(Some(&u), &r, None, None, action), | |
| 360 | + Err(Denied::NotFound) | |
| 361 | + ); | |
| 362 | + } | |
| 363 | + } | |
| 364 | + | |
| 365 | + #[test] | |
| 366 | + fn archived_denies_write_even_for_owner_and_admin_but_not_read_or_admin() { | |
| 367 | + let r = repo(Visibility::Public, true); | |
| 368 | + let owner = user("owner", false); | |
| 369 | + let root = user("root", true); | |
| 370 | + for actor in [&owner, &root] { | |
| 371 | + assert_eq!( | |
| 372 | + authorize(Some(actor), &r, None, None, Action::Write), | |
| 373 | + Err(Denied::Forbidden) | |
| 374 | + ); | |
| 375 | + assert_eq!(authorize(Some(actor), &r, None, None, Action::Read), Ok(())); | |
| 376 | + assert_eq!( | |
| 377 | + authorize(Some(actor), &r, None, None, Action::Admin), | |
| 378 | + Ok(()) | |
| 379 | + ); | |
| 380 | + } | |
| 381 | + let maintainer = user("m", false); | |
| 382 | + assert_eq!( | |
| 383 | + authorize( | |
| 384 | + Some(&maintainer), | |
| 385 | + &r, | |
| 386 | + Some(Role::Maintain), | |
| 387 | + None, | |
| 388 | + Action::Write | |
| 389 | + ), | |
| 390 | + Err(Denied::Forbidden) | |
| 391 | + ); | |
| 392 | + } | |
| 393 | + | |
| 394 | + #[test] | |
| 395 | + fn delete_is_owner_and_instance_admin_only() { | |
| 396 | + let stranger = user("stranger", false); | |
| 397 | + let owner = user("owner", false); | |
| 398 | + let root = user("root", true); | |
| 399 | + for visibility in [Visibility::Public, Visibility::Private] { | |
| 400 | + let r = repo(visibility, false); | |
| 401 | + assert_eq!( | |
| 402 | + authorize(Some(&owner), &r, None, None, Action::Delete), | |
| 403 | + Ok(()) | |
| 404 | + ); | |
| 405 | + assert_eq!( | |
| 406 | + authorize(Some(&root), &r, None, None, Action::Delete), | |
| 407 | + Ok(()) | |
| 408 | + ); | |
| 409 | + // A collaborator with the top role still cannot delete. | |
| 410 | + assert_eq!( | |
| 411 | + authorize(Some(&stranger), &r, Some(Role::Admin), None, Action::Delete), | |
| 412 | + Err(Denied::Forbidden) | |
| 413 | + ); | |
| 414 | + assert_eq!( | |
| 415 | + authorize(None, &r, Some(Role::Admin), None, Action::Delete), | |
| 416 | + if visibility == Visibility::Public { | |
| 417 | + Err(Denied::Forbidden) | |
| 418 | + } else { | |
| 419 | + Err(Denied::NotFound) | |
| 420 | + } | |
| 421 | + ); | |
| 422 | + } | |
| 423 | + // Visible to a stranger on a public repo: 403. Hidden: 404. | |
| 424 | + let public = repo(Visibility::Public, false); | |
| 425 | + assert_eq!( | |
| 426 | + authorize(Some(&stranger), &public, None, None, Action::Delete), | |
| 427 | + Err(Denied::Forbidden) | |
| 428 | + ); | |
| 429 | + let private = repo(Visibility::Private, false); | |
| 430 | + assert_eq!( | |
| 431 | + authorize(Some(&stranger), &private, None, None, Action::Delete), | |
| 432 | + Err(Denied::NotFound) | |
| 433 | + ); | |
| 434 | + // Archiving does not stop an owner from deleting. | |
| 435 | + let archived = repo(Visibility::Private, true); | |
| 436 | + assert_eq!( | |
| 437 | + authorize(Some(&owner), &archived, None, None, Action::Delete), | |
| 438 | + Ok(()) | |
| 439 | + ); | |
| 440 | + } | |
| 441 | + | |
| 442 | + #[test] | |
| 443 | + fn triage_is_granted_to_owner_admin_and_triage_plus_write_roles() { | |
| 444 | + let stranger = user("stranger", false); | |
| 445 | + let owner = user("owner", false); | |
| 446 | + let root = user("root", true); | |
| 447 | + let private = repo(Visibility::Private, false); | |
| 448 | + assert_eq!( | |
| 449 | + authorize(Some(&owner), &private, None, None, Action::Triage), | |
| 450 | + Ok(()) | |
| 451 | + ); | |
| 452 | + assert_eq!( | |
| 453 | + authorize(Some(&root), &private, None, None, Action::Triage), | |
| 454 | + Ok(()) | |
| 455 | + ); | |
| 456 | + assert_eq!( | |
| 457 | + authorize(Some(&stranger), &private, None, None, Action::Triage), | |
| 458 | + Err(Denied::NotFound) | |
| 459 | + ); | |
| 460 | + let public = repo(Visibility::Public, false); | |
| 461 | + assert_eq!( | |
| 462 | + authorize( | |
| 463 | + Some(&stranger), | |
| 464 | + &public, | |
| 465 | + Some(Role::Read), | |
| 466 | + None, | |
| 467 | + Action::Triage | |
| 468 | + ), | |
| 469 | + Err(Denied::Forbidden) | |
| 470 | + ); | |
| 471 | + assert_eq!( | |
| 472 | + authorize(None, &public, None, None, Action::Triage), | |
| 473 | + Err(Denied::Forbidden) | |
| 474 | + ); | |
| 475 | + assert_eq!( | |
| 476 | + authorize(None, &private, None, None, Action::Triage), | |
| 477 | + Err(Denied::NotFound) | |
| 478 | + ); | |
| 479 | + } | |
| 480 | + | |
| 481 | + #[test] | |
| 482 | + fn triage_is_not_blocked_by_archived() { | |
| 483 | + let archived = repo(Visibility::Public, true); | |
| 484 | + let owner = user("owner", false); | |
| 485 | + let triager = user("t", false); | |
| 486 | + assert_eq!( | |
| 487 | + authorize(Some(&owner), &archived, None, None, Action::Triage), | |
| 488 | + Ok(()) | |
| 489 | + ); | |
| 490 | + assert_eq!( | |
| 491 | + authorize( | |
| 492 | + Some(&triager), | |
| 493 | + &archived, | |
| 494 | + Some(Role::Triage), | |
| 495 | + None, | |
| 496 | + Action::Triage | |
| 497 | + ), | |
| 498 | + Ok(()) | |
| 499 | + ); | |
| 500 | + } | |
| 501 | + | |
| 502 | + #[test] | |
| 503 | + fn role_is_ignored_without_an_actor() { | |
| 504 | + let r = repo(Visibility::Private, false); | |
| 505 | + assert_eq!( | |
| 506 | + authorize(None, &r, Some(Role::Admin), None, Action::Read), | |
| 507 | + Err(Denied::NotFound) | |
| 508 | + ); | |
| 509 | + } | |
| 510 | + | |
| 511 | + #[test] | |
| 512 | + fn max_role_takes_the_higher_of_two() { | |
| 513 | + assert_eq!(max_role(None, None), None); | |
| 514 | + assert_eq!(max_role(Some(Role::Read), None), Some(Role::Read)); | |
| 515 | + assert_eq!(max_role(None, Some(Role::Write)), Some(Role::Write)); | |
| 516 | + assert_eq!( | |
| 517 | + max_role(Some(Role::Maintain), Some(Role::Triage)), | |
| 518 | + Some(Role::Maintain) | |
| 519 | + ); | |
| 520 | + assert_eq!( | |
| 521 | + max_role(Some(Role::Read), Some(Role::Admin)), | |
| 522 | + Some(Role::Admin) | |
| 523 | + ); | |
| 524 | + } | |
| 525 | + | |
| 526 | + #[test] | |
| 527 | + fn org_role_grants_what_the_collaborator_role_would() { | |
| 528 | + let r = repo(Visibility::Private, false); | |
| 529 | + let member = user("member", false); | |
| 530 | + // Read-only collaborator, but the org gives Write. | |
| 531 | + assert_eq!( | |
| 532 | + authorize( | |
| 533 | + Some(&member), | |
| 534 | + &r, | |
| 535 | + Some(Role::Read), | |
| 536 | + Some(Role::Write), | |
| 537 | + Action::Write | |
| 538 | + ), | |
| 539 | + Ok(()) | |
| 540 | + ); | |
| 541 | + // An org role alone makes a private repo visible. | |
| 542 | + assert_eq!( | |
| 543 | + authorize(Some(&member), &r, None, Some(Role::Read), Action::Read), | |
| 544 | + Ok(()) | |
| 545 | + ); | |
| 546 | + assert_eq!( | |
| 547 | + authorize(Some(&member), &r, None, None, Action::Read), | |
| 548 | + Err(Denied::NotFound) | |
| 549 | + ); | |
| 550 | + } | |
| 551 | + | |
| 552 | + #[test] | |
| 553 | + fn org_admin_may_delete_but_collaborator_admin_may_not() { | |
| 554 | + let r = repo(Visibility::Private, false); | |
| 555 | + let member = user("member", false); | |
| 556 | + assert_eq!( | |
| 557 | + authorize(Some(&member), &r, None, Some(Role::Admin), Action::Delete), | |
| 558 | + Ok(()) | |
| 559 | + ); | |
| 560 | + assert_eq!( | |
| 561 | + authorize(Some(&member), &r, Some(Role::Admin), None, Action::Delete), | |
| 562 | + Err(Denied::Forbidden) | |
| 563 | + ); | |
| 564 | + assert_eq!( | |
| 565 | + authorize(None, &r, None, Some(Role::Admin), Action::Delete), | |
| 566 | + Err(Denied::NotFound) | |
| 567 | + ); | |
| 568 | + } | |
| 569 | +} |
crates/forge-core/src/avatar.rs| @@ -0,0 +1,182 @@ | ||
| 1 | +//! Avatar upload processing: validate, decode with limits, crop, resize and re-encode as PNG. | |
| 2 | +//! | |
| 3 | +//! Re-encoding to PNG drops all metadata (EXIF, GPS, comments) from the input. | |
| 4 | + | |
| 5 | +use std::io::Cursor; | |
| 6 | + | |
| 7 | +use image::{ImageFormat, ImageReader, Limits, imageops::FilterType}; | |
| 8 | +use sha2::{Digest, Sha256}; | |
| 9 | + | |
| 10 | +use crate::error::{Error, Result}; | |
| 11 | + | |
| 12 | +/// Largest accepted upload, in bytes. | |
| 13 | +pub const MAX_UPLOAD_BYTES: usize = 5 * 1024 * 1024; | |
| 14 | + | |
| 15 | +/// Largest accepted width or height of the decoded source image, in pixels. | |
| 16 | +const MAX_SOURCE_DIMENSION: u32 = 4096; | |
| 17 | + | |
| 18 | +/// Upper bound on decoder allocations (128 MiB). | |
| 19 | +const MAX_ALLOC_BYTES: u64 = 128 * 1024 * 1024; | |
| 20 | + | |
| 21 | +/// Edge length of the stored avatar, in pixels. | |
| 22 | +const AVATAR_SIZE: u32 = 256; | |
| 23 | + | |
| 24 | +#[derive(Debug, Clone, PartialEq, Eq)] | |
| 25 | +pub struct ProcessedAvatar { | |
| 26 | + pub png: Vec<u8>, | |
| 27 | + pub hash: String, | |
| 28 | +} | |
| 29 | + | |
| 30 | +/// Decode with limits (max 4096x4096, image::Limits), center-crop to a square, resize to 256x256 | |
| 31 | +/// (Lanczos3), re-encode as PNG. Metadata (EXIF/GPS) is dropped by re-encoding. SVG, unknown | |
| 32 | +/// formats, too-large input or a decode failure give Error::Invalid. | |
| 33 | +pub fn process_avatar(bytes: &[u8]) -> Result<ProcessedAvatar> { | |
| 34 | + if bytes.len() > MAX_UPLOAD_BYTES { | |
| 35 | + return Err(Error::Invalid(format!( | |
| 36 | + "avatar is larger than {MAX_UPLOAD_BYTES} bytes" | |
| 37 | + ))); | |
| 38 | + } | |
| 39 | + | |
| 40 | + // Format is decided from magic bytes only; the client-supplied type is never consulted. | |
| 41 | + let mut reader = ImageReader::new(Cursor::new(bytes)) | |
| 42 | + .with_guessed_format() | |
| 43 | + .map_err(|_| Error::Invalid("unreadable image header".into()))?; | |
| 44 | + match reader.format() { | |
| 45 | + Some(ImageFormat::Png | ImageFormat::Jpeg | ImageFormat::WebP | ImageFormat::Gif) => {} | |
| 46 | + _ => { | |
| 47 | + return Err(Error::Invalid( | |
| 48 | + "avatar must be a PNG, JPEG, WebP or GIF image".into(), | |
| 49 | + )); | |
| 50 | + } | |
| 51 | + } | |
| 52 | + | |
| 53 | + let mut limits = Limits::default(); | |
| 54 | + limits.max_image_width = Some(MAX_SOURCE_DIMENSION); | |
| 55 | + limits.max_image_height = Some(MAX_SOURCE_DIMENSION); | |
| 56 | + limits.max_alloc = Some(MAX_ALLOC_BYTES); | |
| 57 | + reader.limits(limits); | |
| 58 | + | |
| 59 | + let decoded = reader | |
| 60 | + .decode() | |
| 61 | + .map_err(|e| Error::Invalid(format!("could not decode avatar: {e}")))?; | |
| 62 | + | |
| 63 | + // Center-crop to the largest square, then scale down to the avatar size. | |
| 64 | + let (width, height) = (decoded.width(), decoded.height()); | |
| 65 | + let side = width.min(height); | |
| 66 | + let x = (width - side) / 2; | |
| 67 | + let y = (height - side) / 2; | |
| 68 | + let square = decoded.crop_imm(x, y, side, side); | |
| 69 | + let resized = square.resize_exact(AVATAR_SIZE, AVATAR_SIZE, FilterType::Lanczos3); | |
| 70 | + | |
| 71 | + let mut png = Cursor::new(Vec::new()); | |
| 72 | + resized | |
| 73 | + .write_to(&mut png, ImageFormat::Png) | |
| 74 | + .map_err(|e| Error::Invalid(format!("could not encode avatar: {e}")))?; | |
| 75 | + let png = png.into_inner(); | |
| 76 | + | |
| 77 | + let digest = Sha256::digest(&png); | |
| 78 | + let hash = hex::encode(&digest[..8]); | |
| 79 | + | |
| 80 | + Ok(ProcessedAvatar { png, hash }) | |
| 81 | +} | |
| 82 | + | |
| 83 | +#[cfg(test)] | |
| 84 | +mod tests { | |
| 85 | + use super::*; | |
| 86 | + use image::{DynamicImage, RgbImage}; | |
| 87 | + | |
| 88 | + const PNG_MAGIC: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]; | |
| 89 | + | |
| 90 | + fn encode(img: &DynamicImage, format: ImageFormat) -> Vec<u8> { | |
| 91 | + let mut out = Cursor::new(Vec::new()); | |
| 92 | + img.write_to(&mut out, format).expect("encode test image"); | |
| 93 | + out.into_inner() | |
| 94 | + } | |
| 95 | + | |
| 96 | + fn sample(width: u32, height: u32) -> DynamicImage { | |
| 97 | + DynamicImage::ImageRgb8(RgbImage::from_fn(width, height, |x, y| { | |
| 98 | + image::Rgb([(x % 256) as u8, (y % 256) as u8, 128]) | |
| 99 | + })) | |
| 100 | + } | |
| 101 | + | |
| 102 | + fn crc32(data: &[u8]) -> u32 { | |
| 103 | + let mut crc = 0xFFFF_FFFFu32; | |
| 104 | + for &byte in data { | |
| 105 | + crc ^= u32::from(byte); | |
| 106 | + for _ in 0..8 { | |
| 107 | + let mask = (crc & 1).wrapping_neg(); | |
| 108 | + crc = (crc >> 1) ^ (0xEDB8_8320 & mask); | |
| 109 | + } | |
| 110 | + } | |
| 111 | + !crc | |
| 112 | + } | |
| 113 | + | |
| 114 | + #[test] | |
| 115 | + fn png_is_cropped_and_resized_to_256() { | |
| 116 | + let input = encode(&sample(300, 200), ImageFormat::Png); | |
| 117 | + let out = process_avatar(&input).expect("png accepted"); | |
| 118 | + assert_eq!(&out.png[..8], &PNG_MAGIC); | |
| 119 | + let decoded = image::load_from_memory_with_format(&out.png, ImageFormat::Png).unwrap(); | |
| 120 | + assert_eq!((decoded.width(), decoded.height()), (256, 256)); | |
| 121 | + } | |
| 122 | + | |
| 123 | + #[test] | |
| 124 | + fn jpeg_is_accepted() { | |
| 125 | + let input = encode(&sample(300, 200), ImageFormat::Jpeg); | |
| 126 | + let out = process_avatar(&input).expect("jpeg accepted"); | |
| 127 | + assert_eq!(&out.png[..8], &PNG_MAGIC); | |
| 128 | + } | |
| 129 | + | |
| 130 | + #[test] | |
| 131 | + fn svg_is_rejected() { | |
| 132 | + let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"></svg>"#; | |
| 133 | + assert!(matches!(process_avatar(svg), Err(Error::Invalid(_)))); | |
| 134 | + } | |
| 135 | + | |
| 136 | + #[test] | |
| 137 | + fn random_bytes_are_rejected() { | |
| 138 | + let junk: Vec<u8> = (0..4096u32) | |
| 139 | + .map(|i| (i.wrapping_mul(2654435761) >> 13) as u8) | |
| 140 | + .collect(); | |
| 141 | + assert!(matches!(process_avatar(&junk), Err(Error::Invalid(_)))); | |
| 142 | + assert!(matches!(process_avatar(&[]), Err(Error::Invalid(_)))); | |
| 143 | + } | |
| 144 | + | |
| 145 | + #[test] | |
| 146 | + fn oversized_input_is_rejected() { | |
| 147 | + let input = vec![0u8; MAX_UPLOAD_BYTES + 1]; | |
| 148 | + assert!(matches!(process_avatar(&input), Err(Error::Invalid(_)))); | |
| 149 | + } | |
| 150 | + | |
| 151 | + #[test] | |
| 152 | + fn huge_declared_dimensions_are_rejected() { | |
| 153 | + // Take a valid tiny PNG and rewrite its IHDR to claim 50000x50000, fixing the CRC so | |
| 154 | + // only the dimension limit can reject it. | |
| 155 | + let mut png = encode(&sample(2, 2), ImageFormat::Png); | |
| 156 | + // Layout: 8-byte signature, 4-byte length, 4-byte "IHDR", then width and height. | |
| 157 | + assert_eq!(&png[12..16], b"IHDR"); | |
| 158 | + png[16..20].copy_from_slice(&50_000u32.to_be_bytes()); | |
| 159 | + png[20..24].copy_from_slice(&50_000u32.to_be_bytes()); | |
| 160 | + let crc = crc32(&png[12..29]); | |
| 161 | + png[29..33].copy_from_slice(&crc.to_be_bytes()); | |
| 162 | + assert!(matches!(process_avatar(&png), Err(Error::Invalid(_)))); | |
| 163 | + } | |
| 164 | + | |
| 165 | + #[test] | |
| 166 | + fn output_starts_with_png_magic() { | |
| 167 | + let input = encode(&sample(64, 64), ImageFormat::Png); | |
| 168 | + let out = process_avatar(&input).unwrap(); | |
| 169 | + assert_eq!(&out.png[..8], &PNG_MAGIC); | |
| 170 | + } | |
| 171 | + | |
| 172 | + #[test] | |
| 173 | + fn hash_is_stable_and_well_formed() { | |
| 174 | + let input = encode(&sample(120, 90), ImageFormat::Png); | |
| 175 | + let a = process_avatar(&input).unwrap(); | |
| 176 | + let b = process_avatar(&input).unwrap(); | |
| 177 | + assert_eq!(a.hash, b.hash); | |
| 178 | + assert_eq!(a.hash.len(), 16); | |
| 179 | + assert!(a.hash.bytes().all(|c| c.is_ascii_hexdigit())); | |
| 180 | + assert_eq!(a.png, b.png); | |
| 181 | + } | |
| 182 | +} |
crates/forge-core/src/ci.rs| @@ -0,0 +1,185 @@ | ||
| 1 | +//! CI models: run and job states, runners, runs, jobs and artifacts. The stored shape is in | |
| 2 | +//! migrations/0024_ci.sql; the store functions live in store/ci.rs. | |
| 3 | +//! | |
| 4 | +//! Job specs are kept as JSON text (`CiJob::spec_json`). The store reads only the `needs` and | |
| 5 | +//! `runs_on` arrays out of it, so this crate does not depend on forge-ci. | |
| 6 | + | |
| 7 | +use std::str::FromStr; | |
| 8 | + | |
| 9 | +use crate::error::{Error, Result}; | |
| 10 | + | |
| 11 | +/// State of a run. Stored as the `as_str` name. | |
| 12 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 13 | +pub enum RunState { | |
| 14 | + WaitingApproval, | |
| 15 | + Queued, | |
| 16 | + Running, | |
| 17 | + Success, | |
| 18 | + Failure, | |
| 19 | + Cancelled, | |
| 20 | + Error, | |
| 21 | +} | |
| 22 | + | |
| 23 | +impl RunState { | |
| 24 | + pub fn as_str(self) -> &'static str { | |
| 25 | + match self { | |
| 26 | + RunState::WaitingApproval => "waiting_approval", | |
| 27 | + RunState::Queued => "queued", | |
| 28 | + RunState::Running => "running", | |
| 29 | + RunState::Success => "success", | |
| 30 | + RunState::Failure => "failure", | |
| 31 | + RunState::Cancelled => "cancelled", | |
| 32 | + RunState::Error => "error", | |
| 33 | + } | |
| 34 | + } | |
| 35 | + | |
| 36 | + /// True for states a run cannot leave. | |
| 37 | + pub fn is_terminal(self) -> bool { | |
| 38 | + matches!( | |
| 39 | + self, | |
| 40 | + RunState::Success | RunState::Failure | RunState::Cancelled | RunState::Error | |
| 41 | + ) | |
| 42 | + } | |
| 43 | +} | |
| 44 | + | |
| 45 | +impl FromStr for RunState { | |
| 46 | + type Err = Error; | |
| 47 | + | |
| 48 | + fn from_str(s: &str) -> Result<Self> { | |
| 49 | + Ok(match s { | |
| 50 | + "waiting_approval" => RunState::WaitingApproval, | |
| 51 | + "queued" => RunState::Queued, | |
| 52 | + "running" => RunState::Running, | |
| 53 | + "success" => RunState::Success, | |
| 54 | + "failure" => RunState::Failure, | |
| 55 | + "cancelled" => RunState::Cancelled, | |
| 56 | + "error" => RunState::Error, | |
| 57 | + other => return Err(Error::Database(format!("unknown CI run state {other:?}"))), | |
| 58 | + }) | |
| 59 | + } | |
| 60 | +} | |
| 61 | + | |
| 62 | +/// State of a job. Stored as the `as_str` name. | |
| 63 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 64 | +pub enum JobState { | |
| 65 | + Blocked, | |
| 66 | + Queued, | |
| 67 | + Running, | |
| 68 | + Success, | |
| 69 | + Failure, | |
| 70 | + Cancelled, | |
| 71 | + Error, | |
| 72 | + Skipped, | |
| 73 | +} | |
| 74 | + | |
| 75 | +impl JobState { | |
| 76 | + pub fn as_str(self) -> &'static str { | |
| 77 | + match self { | |
| 78 | + JobState::Blocked => "blocked", | |
| 79 | + JobState::Queued => "queued", | |
| 80 | + JobState::Running => "running", | |
| 81 | + JobState::Success => "success", | |
| 82 | + JobState::Failure => "failure", | |
| 83 | + JobState::Cancelled => "cancelled", | |
| 84 | + JobState::Error => "error", | |
| 85 | + JobState::Skipped => "skipped", | |
| 86 | + } | |
| 87 | + } | |
| 88 | + | |
| 89 | + /// True for states a job cannot leave. | |
| 90 | + pub fn is_terminal(self) -> bool { | |
| 91 | + matches!( | |
| 92 | + self, | |
| 93 | + JobState::Success | |
| 94 | + | JobState::Failure | |
| 95 | + | JobState::Cancelled | |
| 96 | + | JobState::Error | |
| 97 | + | JobState::Skipped | |
| 98 | + ) | |
| 99 | + } | |
| 100 | +} | |
| 101 | + | |
| 102 | +impl FromStr for JobState { | |
| 103 | + type Err = Error; | |
| 104 | + | |
| 105 | + fn from_str(s: &str) -> Result<Self> { | |
| 106 | + Ok(match s { | |
| 107 | + "blocked" => JobState::Blocked, | |
| 108 | + "queued" => JobState::Queued, | |
| 109 | + "running" => JobState::Running, | |
| 110 | + "success" => JobState::Success, | |
| 111 | + "failure" => JobState::Failure, | |
| 112 | + "cancelled" => JobState::Cancelled, | |
| 113 | + "error" => JobState::Error, | |
| 114 | + "skipped" => JobState::Skipped, | |
| 115 | + other => return Err(Error::Database(format!("unknown CI job state {other:?}"))), | |
| 116 | + }) | |
| 117 | + } | |
| 118 | +} | |
| 119 | + | |
| 120 | +/// A registered runner. The secret and its hash are never part of this type. | |
| 121 | +#[derive(Debug, Clone, PartialEq, Eq)] | |
| 122 | +pub struct CiRunner { | |
| 123 | + pub id: String, | |
| 124 | + pub name: String, | |
| 125 | + pub labels: Vec<String>, | |
| 126 | + pub created_at: String, | |
| 127 | + pub last_seen_at: Option<String>, | |
| 128 | + pub disabled: bool, | |
| 129 | +} | |
| 130 | + | |
| 131 | +/// One run of the workflow for a commit. | |
| 132 | +#[derive(Debug, Clone, PartialEq, Eq)] | |
| 133 | +pub struct CiRun { | |
| 134 | + pub id: String, | |
| 135 | + pub repo_id: String, | |
| 136 | + pub number: i64, | |
| 137 | + /// `push`, `tag`, `pull_request` or `manual`. | |
| 138 | + pub trigger: String, | |
| 139 | + pub git_ref: String, | |
| 140 | + pub commit_oid: String, | |
| 141 | + pub pull_number: Option<i64>, | |
| 142 | + pub actor_id: String, | |
| 143 | + pub state: RunState, | |
| 144 | + pub config_error: Option<String>, | |
| 145 | + pub created_at: String, | |
| 146 | + pub finished_at: Option<String>, | |
| 147 | +} | |
| 148 | + | |
| 149 | +/// One job of a run. `spec_json` is the JobSpec as JSON text. | |
| 150 | +#[derive(Debug, Clone, PartialEq, Eq)] | |
| 151 | +pub struct CiJob { | |
| 152 | + pub id: String, | |
| 153 | + pub run_id: String, | |
| 154 | + pub name: String, | |
| 155 | + pub spec_json: String, | |
| 156 | + pub state: JobState, | |
| 157 | + pub runner_id: Option<String>, | |
| 158 | + pub exit_code: Option<i64>, | |
| 159 | + pub message: Option<String>, | |
| 160 | + pub queued_at: Option<String>, | |
| 161 | + pub started_at: Option<String>, | |
| 162 | + pub finished_at: Option<String>, | |
| 163 | + pub heartbeat_at: Option<String>, | |
| 164 | + pub cancel_requested: bool, | |
| 165 | + pub log_bytes: i64, | |
| 166 | +} | |
| 167 | + | |
| 168 | +/// An artifact uploaded by a job. The file itself is stored outside the database. | |
| 169 | +#[derive(Debug, Clone, PartialEq, Eq)] | |
| 170 | +pub struct CiArtifact { | |
| 171 | + pub id: String, | |
| 172 | + pub job_id: String, | |
| 173 | + pub name: String, | |
| 174 | + pub size: i64, | |
| 175 | + pub sha256: String, | |
| 176 | + pub created_at: String, | |
| 177 | +} | |
| 178 | + | |
| 179 | +/// A job to create with a run. `needs` must match the `needs` in `spec_json`. | |
| 180 | +#[derive(Debug, Clone, Copy)] | |
| 181 | +pub struct NewJob<'a> { | |
| 182 | + pub name: &'a str, | |
| 183 | + pub spec_json: &'a str, | |
| 184 | + pub needs: &'a [String], | |
| 185 | +} |
crates/forge-core/src/config.rs| @@ -0,0 +1,570 @@ | ||
| 1 | +//! Instance configuration, read from TOML. | |
| 2 | + | |
| 3 | +use std::net::{IpAddr, SocketAddr}; | |
| 4 | +use std::path::{Path, PathBuf}; | |
| 5 | + | |
| 6 | +use ipnet::IpNet; | |
| 7 | +use serde::{Deserialize, Deserializer}; | |
| 8 | + | |
| 9 | +use crate::error::{Error, Result}; | |
| 10 | + | |
| 11 | +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] | |
| 12 | +#[serde(rename_all = "lowercase")] | |
| 13 | +pub enum Registration { | |
| 14 | + Closed, | |
| 15 | + Invite, | |
| 16 | + Open, | |
| 17 | +} | |
| 18 | + | |
| 19 | +/// Parses each entry as a CIDR network or as a single address (a /32 or /128). | |
| 20 | +fn deserialize_trusted_proxies<'de, D: Deserializer<'de>>( | |
| 21 | + deserializer: D, | |
| 22 | +) -> std::result::Result<Vec<IpNet>, D::Error> { | |
| 23 | + let entries = Vec::<String>::deserialize(deserializer)?; | |
| 24 | + entries | |
| 25 | + .iter() | |
| 26 | + .map(|entry| { | |
| 27 | + if let Ok(net) = entry.parse::<IpNet>() { | |
| 28 | + return Ok(net); | |
| 29 | + } | |
| 30 | + entry | |
| 31 | + .parse::<IpAddr>() | |
| 32 | + .map(IpNet::from) | |
| 33 | + .map_err(|_| serde::de::Error::custom(format!("invalid trusted proxy `{entry}`"))) | |
| 34 | + }) | |
| 35 | + .collect() | |
| 36 | +} | |
| 37 | + | |
| 38 | +fn default_user_content_listen() -> SocketAddr { | |
| 39 | + "127.0.0.1:7661" | |
| 40 | + .parse() | |
| 41 | + .expect("valid default socket address") | |
| 42 | +} | |
| 43 | + | |
| 44 | +/// Outbound webhook delivery policy. Internal destinations are refused unless listed in `allowed_hosts`. | |
| 45 | +#[derive(Debug, Clone, Default, Deserialize)] | |
| 46 | +#[serde(deny_unknown_fields)] | |
| 47 | +pub struct WebhookConfig { | |
| 48 | + /// Permit `http://` hook URLs in addition to `https://`. | |
| 49 | + #[serde(default)] | |
| 50 | + pub allow_http: bool, | |
| 51 | + /// Exact hostnames or CIDR networks that may be reached even when they resolve to internal addresses. | |
| 52 | + #[serde(default)] | |
| 53 | + pub allowed_hosts: Vec<String>, | |
| 54 | +} | |
| 55 | + | |
| 56 | +#[derive(Debug, Clone, Deserialize)] | |
| 57 | +#[serde(deny_unknown_fields)] | |
| 58 | +pub struct Config { | |
| 59 | + pub database: PathBuf, | |
| 60 | + pub data_dir: PathBuf, | |
| 61 | + pub listen: SocketAddr, | |
| 62 | + pub base_url: String, | |
| 63 | + pub user_content_base_url: String, | |
| 64 | + /// Listener that serves raw user files on the separate user-content origin. | |
| 65 | + #[serde(default = "default_user_content_listen")] | |
| 66 | + pub user_content_listen: SocketAddr, | |
| 67 | + pub ssh_listen: SocketAddr, | |
| 68 | + pub ssh_public_host: String, | |
| 69 | + pub ssh_public_port: u16, | |
| 70 | + pub registration: Registration, | |
| 71 | + /// Peers whose `X-Forwarded-For` header is trusted, such as the reverse proxy in front of forge. | |
| 72 | + /// Entries are addresses or CIDR networks. Empty by default, so the TCP peer is the client. | |
| 73 | + #[serde(default, deserialize_with = "deserialize_trusted_proxies")] | |
| 74 | + pub trusted_proxies: Vec<IpNet>, | |
| 75 | + /// Outbound webhook policy, from the `[webhooks]` section. | |
| 76 | + #[serde(default)] | |
| 77 | + pub webhooks: WebhookConfig, | |
| 78 | + /// Scheduled backups and fsck, from the `[backup]` section. An empty `dir` disables both. | |
| 79 | + #[serde(default)] | |
| 80 | + pub backup: BackupConfig, | |
| 81 | + /// Release asset limits, from the `[releases]` section. | |
| 82 | + #[serde(default)] | |
| 83 | + pub releases: ReleasesConfig, | |
| 84 | + /// Standby replication, from the `[replication]` section. An empty `target` disables it. | |
| 85 | + #[serde(default)] | |
| 86 | + pub replication: ReplicationConfig, | |
| 87 | + /// Git LFS limits, from the `[lfs]` section. | |
| 88 | + #[serde(default)] | |
| 89 | + pub lfs: LfsConfig, | |
| 90 | + /// CI limits and retention, from the `[ci]` section. | |
| 91 | + #[serde(default)] | |
| 92 | + pub ci: CiConfig, | |
| 93 | + /// Security advisories (opt-in), from the `[advisories]` section. | |
| 94 | + #[serde(default)] | |
| 95 | + pub advisories: AdvisoriesConfig, | |
| 96 | + /// Outgoing mail transport, from the `[mail]` section. The SMTP account is set in the database. | |
| 97 | + #[serde(default)] | |
| 98 | + pub mail: MailConfig, | |
| 99 | + /// Organization rules, from the `[orgs]` section. | |
| 100 | + #[serde(default)] | |
| 101 | + pub orgs: OrgsConfig, | |
| 102 | +} | |
| 103 | + | |
| 104 | +/// Organization rules, from the `[orgs]` section. | |
| 105 | +#[derive(Debug, Clone, Default, Deserialize)] | |
| 106 | +#[serde(deny_unknown_fields, default)] | |
| 107 | +pub struct OrgsConfig { | |
| 108 | + /// Who may create an organization: `all` (any signed-in person, the default) or `admin` | |
| 109 | + /// (instance admins only). | |
| 110 | + pub creation: OrgCreation, | |
| 111 | +} | |
| 112 | + | |
| 113 | +/// Who may create organizations. See `OrgsConfig::creation`. | |
| 114 | +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] | |
| 115 | +#[serde(rename_all = "lowercase")] | |
| 116 | +pub enum OrgCreation { | |
| 117 | + #[default] | |
| 118 | + All, | |
| 119 | + Admin, | |
| 120 | +} | |
| 121 | + | |
| 122 | +/// Outgoing mail transport, from the `[mail]` section. The SMTP server, sender and password are | |
| 123 | +/// set on `/admin/mail` and stored in the database; this section only picks the transport. | |
| 124 | +#[derive(Debug, Clone, Default, Deserialize)] | |
| 125 | +#[serde(deny_unknown_fields, default)] | |
| 126 | +pub struct MailConfig { | |
| 127 | + pub transport: MailTransport, | |
| 128 | +} | |
| 129 | + | |
| 130 | +/// `smtp` sends through the configured server. `log` writes each message to `<data_dir>/mail-log/` | |
| 131 | +/// instead, for development and tests. | |
| 132 | +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] | |
| 133 | +#[serde(rename_all = "lowercase")] | |
| 134 | +pub enum MailTransport { | |
| 135 | + #[default] | |
| 136 | + Smtp, | |
| 137 | + Log, | |
| 138 | +} | |
| 139 | + | |
| 140 | +/// CI settings, from the `[ci]` section. | |
| 141 | +#[derive(Debug, Clone, Deserialize)] | |
| 142 | +#[serde(deny_unknown_fields, default)] | |
| 143 | +pub struct CiConfig { | |
| 144 | + /// Whether CI runs at all. When false, pushes and pull requests create no runs. | |
| 145 | + pub enabled: bool, | |
| 146 | + /// Largest job log, in MiB. Past it the log is truncated. | |
| 147 | + pub max_log_mb: u64, | |
| 148 | + /// Largest artifact, in MiB. | |
| 149 | + pub max_artifact_mb: u64, | |
| 150 | + /// Artifacts a single job may upload. | |
| 151 | + pub max_artifacts_per_job: u32, | |
| 152 | + /// Days a job log is kept. | |
| 153 | + pub log_retention_days: u64, | |
| 154 | + /// Days an artifact is kept. | |
| 155 | + pub artifact_retention_days: u64, | |
| 156 | + /// Hours a runner registration token stays valid. | |
| 157 | + pub registration_token_hours: u32, | |
| 158 | +} | |
| 159 | + | |
| 160 | +impl Default for CiConfig { | |
| 161 | + fn default() -> Self { | |
| 162 | + CiConfig { | |
| 163 | + enabled: true, | |
| 164 | + max_log_mb: 16, | |
| 165 | + max_artifact_mb: 512, | |
| 166 | + max_artifacts_per_job: 50, | |
| 167 | + log_retention_days: 90, | |
| 168 | + artifact_retention_days: 30, | |
| 169 | + registration_token_hours: 24, | |
| 170 | + } | |
| 171 | + } | |
| 172 | +} | |
| 173 | + | |
| 174 | +/// Security advisories, from the `[advisories]` section. Off by default: when on, the forge | |
| 175 | +/// downloads the OSV dumps for `ecosystems` and scans each repo's lockfiles. | |
| 176 | +#[derive(Debug, Clone, Deserialize)] | |
| 177 | +#[serde(deny_unknown_fields, default)] | |
| 178 | +pub struct AdvisoriesConfig { | |
| 179 | + /// Whether the advisory download and the lockfile scans run at all. | |
| 180 | + pub enabled: bool, | |
| 181 | + /// OSV ecosystems to download and scan: any of `crates.io`, `npm`, `Go`. | |
| 182 | + pub ecosystems: Vec<String>, | |
| 183 | + /// Hours before a downloaded dump is fetched again. | |
| 184 | + pub refresh_hours: u64, | |
| 185 | +} | |
| 186 | + | |
| 187 | +impl Default for AdvisoriesConfig { | |
| 188 | + fn default() -> Self { | |
| 189 | + AdvisoriesConfig { | |
| 190 | + enabled: false, | |
| 191 | + ecosystems: vec!["crates.io".into(), "npm".into(), "Go".into()], | |
| 192 | + refresh_hours: 24, | |
| 193 | + } | |
| 194 | + } | |
| 195 | +} | |
| 196 | + | |
| 197 | +/// Git LFS limits, from the `[lfs]` section. | |
| 198 | +#[derive(Debug, Clone, Deserialize)] | |
| 199 | +#[serde(deny_unknown_fields, default)] | |
| 200 | +pub struct LfsConfig { | |
| 201 | + /// Largest accepted LFS object, in MiB. | |
| 202 | + pub max_object_mb: u64, | |
| 203 | +} | |
| 204 | + | |
| 205 | +impl Default for LfsConfig { | |
| 206 | + fn default() -> Self { | |
| 207 | + LfsConfig { | |
| 208 | + max_object_mb: 2048, | |
| 209 | + } | |
| 210 | + } | |
| 211 | +} | |
| 212 | + | |
| 213 | +impl LfsConfig { | |
| 214 | + /// The object size limit in bytes. | |
| 215 | + pub fn max_object_bytes(&self) -> u64 { | |
| 216 | + self.max_object_mb.saturating_mul(1024 * 1024) | |
| 217 | + } | |
| 218 | +} | |
| 219 | + | |
| 220 | +/// Standby replication, from the `[replication]` section. See `docs/backup.md`. | |
| 221 | +#[derive(Debug, Clone, Deserialize)] | |
| 222 | +#[serde(deny_unknown_fields, default)] | |
| 223 | +pub struct ReplicationConfig { | |
| 224 | + /// `[user@]host:/absolute/base` of the standby account. Empty disables replication. | |
| 225 | + pub target: String, | |
| 226 | + /// SSH port of the standby host. | |
| 227 | + pub ssh_port: u16, | |
| 228 | + /// Private key of the standby account (mode 0600). Required when `target` is set. | |
| 229 | + pub identity_file: PathBuf, | |
| 230 | + /// Pinned host keys of the standby. Required when `target` is set; StrictHostKeyChecking is always `yes`. | |
| 231 | + pub known_hosts_file: PathBuf, | |
| 232 | + /// Minutes between database snapshots shipped to the standby. | |
| 233 | + pub db_snapshot_minutes: u64, | |
| 234 | + /// Minutes between reconcile passes (refs checksums, deleted repos, healthcheck). | |
| 235 | + pub reconcile_minutes: u64, | |
| 236 | + /// Optional URL pinged after each reconcile pass. Empty disables it. | |
| 237 | + pub healthcheck_url: String, | |
| 238 | +} | |
| 239 | + | |
| 240 | +impl Default for ReplicationConfig { | |
| 241 | + fn default() -> Self { | |
| 242 | + ReplicationConfig { | |
| 243 | + target: String::new(), | |
| 244 | + ssh_port: 22, | |
| 245 | + identity_file: PathBuf::new(), | |
| 246 | + known_hosts_file: PathBuf::new(), | |
| 247 | + db_snapshot_minutes: 15, | |
| 248 | + reconcile_minutes: 60, | |
| 249 | + healthcheck_url: String::new(), | |
| 250 | + } | |
| 251 | + } | |
| 252 | +} | |
| 253 | + | |
| 254 | +impl ReplicationConfig { | |
| 255 | + /// Whether the replication worker runs at all. | |
| 256 | + pub fn enabled(&self) -> bool { | |
| 257 | + !self.target.is_empty() | |
| 258 | + } | |
| 259 | + | |
| 260 | + fn validate(&self) -> Result<()> { | |
| 261 | + if !self.enabled() { | |
| 262 | + return Ok(()); | |
| 263 | + } | |
| 264 | + if self.identity_file.as_os_str().is_empty() { | |
| 265 | + return Err(Error::Config( | |
| 266 | + "[replication] identity_file is required when target is set".into(), | |
| 267 | + )); | |
| 268 | + } | |
| 269 | + if self.known_hosts_file.as_os_str().is_empty() { | |
| 270 | + return Err(Error::Config( | |
| 271 | + "[replication] known_hosts_file is required when target is set".into(), | |
| 272 | + )); | |
| 273 | + } | |
| 274 | + if self.db_snapshot_minutes == 0 { | |
| 275 | + return Err(Error::Config( | |
| 276 | + "[replication] db_snapshot_minutes must be at least 1".into(), | |
| 277 | + )); | |
| 278 | + } | |
| 279 | + if self.reconcile_minutes == 0 { | |
| 280 | + return Err(Error::Config( | |
| 281 | + "[replication] reconcile_minutes must be at least 1".into(), | |
| 282 | + )); | |
| 283 | + } | |
| 284 | + Ok(()) | |
| 285 | + } | |
| 286 | +} | |
| 287 | + | |
| 288 | +/// Release settings, from the `[releases]` section. | |
| 289 | +#[derive(Debug, Clone, Deserialize)] | |
| 290 | +#[serde(deny_unknown_fields, default)] | |
| 291 | +pub struct ReleasesConfig { | |
| 292 | + /// Largest accepted release asset, in mebibytes. | |
| 293 | + pub max_asset_mb: u64, | |
| 294 | +} | |
| 295 | + | |
| 296 | +impl Default for ReleasesConfig { | |
| 297 | + fn default() -> Self { | |
| 298 | + ReleasesConfig { max_asset_mb: 512 } | |
| 299 | + } | |
| 300 | +} | |
| 301 | + | |
| 302 | +/// Scheduled backups, from the `[backup]` section. See `docs/backup-interfaces.md`. | |
| 303 | +#[derive(Debug, Clone, Deserialize)] | |
| 304 | +#[serde(deny_unknown_fields, default)] | |
| 305 | +pub struct BackupConfig { | |
| 306 | + /// Directory that receives the backup sets. Empty disables the scheduler. | |
| 307 | + pub dir: PathBuf, | |
| 308 | + /// Hours between full backup sets. | |
| 309 | + pub interval_hours: u64, | |
| 310 | + /// Newest complete sets kept in `dir`. Older complete sets are deleted after a good run. | |
| 311 | + pub keep: usize, | |
| 312 | + /// Optional healthchecks.io style URL. Pinged with `/start`, then the URL itself or `/fail`. | |
| 313 | + pub healthcheck_url: String, | |
| 314 | + /// Days between `git fsck` and integrity checks of every repo and the database. | |
| 315 | + pub fsck_interval_days: u64, | |
| 316 | +} | |
| 317 | + | |
| 318 | +impl Default for BackupConfig { | |
| 319 | + fn default() -> Self { | |
| 320 | + BackupConfig { | |
| 321 | + dir: PathBuf::new(), | |
| 322 | + interval_hours: 24, | |
| 323 | + keep: 7, | |
| 324 | + healthcheck_url: String::new(), | |
| 325 | + fsck_interval_days: 7, | |
| 326 | + } | |
| 327 | + } | |
| 328 | +} | |
| 329 | + | |
| 330 | +impl BackupConfig { | |
| 331 | + /// Whether the scheduler runs at all. | |
| 332 | + pub fn enabled(&self) -> bool { | |
| 333 | + !self.dir.as_os_str().is_empty() | |
| 334 | + } | |
| 335 | + | |
| 336 | + fn validate(&self) -> Result<()> { | |
| 337 | + if self.interval_hours == 0 { | |
| 338 | + return Err(Error::Config( | |
| 339 | + "[backup] interval_hours must be at least 1".into(), | |
| 340 | + )); | |
| 341 | + } | |
| 342 | + if self.keep == 0 { | |
| 343 | + return Err(Error::Config("[backup] keep must be at least 1".into())); | |
| 344 | + } | |
| 345 | + if self.fsck_interval_days == 0 { | |
| 346 | + return Err(Error::Config( | |
| 347 | + "[backup] fsck_interval_days must be at least 1".into(), | |
| 348 | + )); | |
| 349 | + } | |
| 350 | + Ok(()) | |
| 351 | + } | |
| 352 | +} | |
| 353 | + | |
| 354 | +/// Development defaults, written when the config file is missing. Keep in sync with `config/forge.example.toml`. | |
| 355 | +const DEV_DEFAULTS: &str = r#"# forge configuration (development defaults). Production values belong in /etc/forge/forge.toml. | |
| 356 | + | |
| 357 | +# SQLite/Turso database file. | |
| 358 | +database = "./data/forge.db" | |
| 359 | +# Root for repos, instance key, gitconfig, hooks and sockets. | |
| 360 | +data_dir = "./data" | |
| 361 | +# HTTP listen address for the web UI and smart-HTTP. | |
| 362 | +listen = "127.0.0.1:7660" | |
| 363 | +# Public base URL used in links and clone URLs. | |
| 364 | +base_url = "http://127.0.0.1:7660" | |
| 365 | +# Separate origin for raw user content (Milestone 2). | |
| 366 | +user_content_base_url = "http://127.0.0.1:7661" | |
| 367 | +# Listener for raw user content, served on a separate origin. | |
| 368 | +user_content_listen = "127.0.0.1:7661" | |
| 369 | +# SSH listen address for git over SSH. | |
| 370 | +ssh_listen = "0.0.0.0:2222" | |
| 371 | +# Host and port shown in SSH clone URLs. | |
| 372 | +ssh_public_host = "127.0.0.1" | |
| 373 | +ssh_public_port = 2222 | |
| 374 | +# Self-registration: "closed" | "invite" | "open". | |
| 375 | +registration = "closed" | |
| 376 | +# Proxies whose X-Forwarded-For is trusted for client IPs (login and git throttling). Addresses or CIDRs. | |
| 377 | +trusted_proxies = [] | |
| 378 | +"#; | |
| 379 | + | |
| 380 | +impl Config { | |
| 381 | + /// Loads the config from `path`. Writes development defaults if the file is missing. | |
| 382 | + pub fn load(path: &Path) -> Result<Config> { | |
| 383 | + let text = match std::fs::read_to_string(path) { | |
| 384 | + Ok(text) => text, | |
| 385 | + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { | |
| 386 | + if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) { | |
| 387 | + std::fs::create_dir_all(parent)?; | |
| 388 | + } | |
| 389 | + std::fs::write(path, DEV_DEFAULTS)?; | |
| 390 | + DEV_DEFAULTS.to_owned() | |
| 391 | + } | |
| 392 | + Err(e) => return Err(e.into()), | |
| 393 | + }; | |
| 394 | + let config: Config = | |
| 395 | + toml::from_str(&text).map_err(|e| Error::Config(format!("{}: {e}", path.display())))?; | |
| 396 | + config.backup.validate()?; | |
| 397 | + config.replication.validate()?; | |
| 398 | + Ok(config) | |
| 399 | + } | |
| 400 | +} | |
| 401 | + | |
| 402 | +#[cfg(test)] | |
| 403 | +mod tests { | |
| 404 | + use super::*; | |
| 405 | + | |
| 406 | + #[test] | |
| 407 | + fn missing_file_writes_dev_defaults() { | |
| 408 | + let dir = tempfile::tempdir().unwrap(); | |
| 409 | + let path = dir.path().join("nested").join("forge.toml"); | |
| 410 | + let cfg = Config::load(&path).unwrap(); | |
| 411 | + assert!(path.exists()); | |
| 412 | + assert_eq!(cfg.database, PathBuf::from("./data/forge.db")); | |
| 413 | + assert_eq!(cfg.listen, "127.0.0.1:7660".parse().unwrap()); | |
| 414 | + assert_eq!(cfg.user_content_listen, "127.0.0.1:7661".parse().unwrap()); | |
| 415 | + assert_eq!(cfg.ssh_listen, "0.0.0.0:2222".parse().unwrap()); | |
| 416 | + assert_eq!(cfg.ssh_public_port, 2222); | |
| 417 | + assert_eq!(cfg.registration, Registration::Closed); | |
| 418 | + // Reloading the written file gives the same config. | |
| 419 | + let again = Config::load(&path).unwrap(); | |
| 420 | + assert_eq!(again.base_url, cfg.base_url); | |
| 421 | + } | |
| 422 | + | |
| 423 | + #[test] | |
| 424 | + fn missing_user_content_listen_uses_default() { | |
| 425 | + let dir = tempfile::tempdir().unwrap(); | |
| 426 | + let path = dir.path().join("forge.toml"); | |
| 427 | + let text: String = DEV_DEFAULTS | |
| 428 | + .lines() | |
| 429 | + .filter(|line| !line.starts_with("user_content_listen")) | |
| 430 | + .collect::<Vec<_>>() | |
| 431 | + .join("\n"); | |
| 432 | + std::fs::write(&path, text).unwrap(); | |
| 433 | + let cfg = Config::load(&path).unwrap(); | |
| 434 | + assert_eq!(cfg.user_content_listen, "127.0.0.1:7661".parse().unwrap()); | |
| 435 | + } | |
| 436 | + | |
| 437 | + #[test] | |
| 438 | + fn user_content_listen_is_parsed() { | |
| 439 | + let dir = tempfile::tempdir().unwrap(); | |
| 440 | + let path = dir.path().join("forge.toml"); | |
| 441 | + let text = DEV_DEFAULTS.replace("127.0.0.1:7661\"\n# SSH", "0.0.0.0:9000\"\n# SSH"); | |
| 442 | + std::fs::write(&path, text).unwrap(); | |
| 443 | + let cfg = Config::load(&path).unwrap(); | |
| 444 | + assert_eq!(cfg.user_content_listen, "0.0.0.0:9000".parse().unwrap()); | |
| 445 | + } | |
| 446 | + | |
| 447 | + #[test] | |
| 448 | + fn unknown_keys_are_rejected() { | |
| 449 | + let dir = tempfile::tempdir().unwrap(); | |
| 450 | + let path = dir.path().join("forge.toml"); | |
| 451 | + let text = DEV_DEFAULTS.replace("registration", "bogus = 1\nregistration"); | |
| 452 | + std::fs::write(&path, text).unwrap(); | |
| 453 | + assert!(matches!(Config::load(&path), Err(Error::Config(_)))); | |
| 454 | + } | |
| 455 | + | |
| 456 | + #[test] | |
| 457 | + fn registration_is_lowercase() { | |
| 458 | + let dir = tempfile::tempdir().unwrap(); | |
| 459 | + let path = dir.path().join("forge.toml"); | |
| 460 | + std::fs::write(&path, DEV_DEFAULTS.replace("\"closed\"", "\"invite\"")).unwrap(); | |
| 461 | + assert_eq!( | |
| 462 | + Config::load(&path).unwrap().registration, | |
| 463 | + Registration::Invite | |
| 464 | + ); | |
| 465 | + std::fs::write(&path, DEV_DEFAULTS.replace("\"closed\"", "\"Invite\"")).unwrap(); | |
| 466 | + assert!(Config::load(&path).is_err()); | |
| 467 | + } | |
| 468 | + | |
| 469 | + #[test] | |
| 470 | + fn trusted_proxies_accept_addresses_and_networks() { | |
| 471 | + let dir = tempfile::tempdir().unwrap(); | |
| 472 | + let path = dir.path().join("forge.toml"); | |
| 473 | + let text = DEV_DEFAULTS.replace( | |
| 474 | + "trusted_proxies = []", | |
| 475 | + "trusted_proxies = [\"127.0.0.1\", \"10.0.0.0/8\", \"2001:db8::/32\"]", | |
| 476 | + ); | |
| 477 | + std::fs::write(&path, text).unwrap(); | |
| 478 | + let cfg = Config::load(&path).unwrap(); | |
| 479 | + assert_eq!(cfg.trusted_proxies.len(), 3); | |
| 480 | + assert_eq!( | |
| 481 | + cfg.trusted_proxies[0], | |
| 482 | + "127.0.0.1/32".parse::<IpNet>().unwrap() | |
| 483 | + ); | |
| 484 | + assert_eq!( | |
| 485 | + cfg.trusted_proxies[1], | |
| 486 | + "10.0.0.0/8".parse::<IpNet>().unwrap() | |
| 487 | + ); | |
| 488 | + } | |
| 489 | + | |
| 490 | + #[test] | |
| 491 | + fn invalid_trusted_proxy_is_rejected() { | |
| 492 | + let dir = tempfile::tempdir().unwrap(); | |
| 493 | + let path = dir.path().join("forge.toml"); | |
| 494 | + let text = | |
| 495 | + DEV_DEFAULTS.replace("trusted_proxies = []", "trusted_proxies = [\"not-an-ip\"]"); | |
| 496 | + std::fs::write(&path, text).unwrap(); | |
| 497 | + assert!(matches!(Config::load(&path), Err(Error::Config(_)))); | |
| 498 | + } | |
| 499 | + | |
| 500 | + #[test] | |
| 501 | + fn backup_section_defaults_to_disabled() { | |
| 502 | + let dir = tempfile::tempdir().unwrap(); | |
| 503 | + let path = dir.path().join("forge.toml"); | |
| 504 | + std::fs::write(&path, DEV_DEFAULTS).unwrap(); | |
| 505 | + let cfg = Config::load(&path).unwrap(); | |
| 506 | + assert!(!cfg.backup.enabled()); | |
| 507 | + assert_eq!(cfg.backup.interval_hours, 24); | |
| 508 | + assert_eq!(cfg.backup.keep, 7); | |
| 509 | + assert_eq!(cfg.backup.fsck_interval_days, 7); | |
| 510 | + assert!(cfg.backup.healthcheck_url.is_empty()); | |
| 511 | + } | |
| 512 | + | |
| 513 | + #[test] | |
| 514 | + fn replication_section_defaults_to_disabled() { | |
| 515 | + let dir = tempfile::tempdir().unwrap(); | |
| 516 | + let path = dir.path().join("forge.toml"); | |
| 517 | + std::fs::write(&path, DEV_DEFAULTS).unwrap(); | |
| 518 | + let cfg = Config::load(&path).unwrap(); | |
| 519 | + assert!(!cfg.replication.enabled()); | |
| 520 | + assert_eq!(cfg.replication.ssh_port, 22); | |
| 521 | + assert_eq!(cfg.replication.db_snapshot_minutes, 15); | |
| 522 | + assert_eq!(cfg.replication.reconcile_minutes, 60); | |
| 523 | + assert!(cfg.replication.healthcheck_url.is_empty()); | |
| 524 | + } | |
| 525 | + | |
| 526 | + #[test] | |
| 527 | + fn replication_target_requires_keys_and_positive_intervals() { | |
| 528 | + let dir = tempfile::tempdir().unwrap(); | |
| 529 | + let path = dir.path().join("forge.toml"); | |
| 530 | + let base = format!("{DEV_DEFAULTS}\n[replication]\ntarget = \"sb@host:/srv/standby\"\n"); | |
| 531 | + std::fs::write(&path, &base).unwrap(); | |
| 532 | + assert!(matches!(Config::load(&path), Err(Error::Config(_)))); | |
| 533 | + std::fs::write( | |
| 534 | + &path, | |
| 535 | + format!("{base}identity_file = \"/k\"\nknown_hosts_file = \"/kh\"\n"), | |
| 536 | + ) | |
| 537 | + .unwrap(); | |
| 538 | + let cfg = Config::load(&path).unwrap(); | |
| 539 | + assert!(cfg.replication.enabled()); | |
| 540 | + assert_eq!(cfg.replication.identity_file, PathBuf::from("/k")); | |
| 541 | + std::fs::write( | |
| 542 | + &path, | |
| 543 | + format!( | |
| 544 | + "{base}identity_file = \"/k\"\nknown_hosts_file = \"/kh\"\nreconcile_minutes = 0\n" | |
| 545 | + ), | |
| 546 | + ) | |
| 547 | + .unwrap(); | |
| 548 | + assert!(matches!(Config::load(&path), Err(Error::Config(_)))); | |
| 549 | + } | |
| 550 | + | |
| 551 | + #[test] | |
| 552 | + fn backup_section_is_parsed_and_validated() { | |
| 553 | + let dir = tempfile::tempdir().unwrap(); | |
| 554 | + let path = dir.path().join("forge.toml"); | |
| 555 | + let text = format!( | |
| 556 | + "{DEV_DEFAULTS}\n[backup]\ndir = \"/srv/backups\"\nkeep = 3\ninterval_hours = 6\n" | |
| 557 | + ); | |
| 558 | + std::fs::write(&path, &text).unwrap(); | |
| 559 | + let cfg = Config::load(&path).unwrap(); | |
| 560 | + assert!(cfg.backup.enabled()); | |
| 561 | + assert_eq!(cfg.backup.dir, PathBuf::from("/srv/backups")); | |
| 562 | + assert_eq!(cfg.backup.keep, 3); | |
| 563 | + assert_eq!(cfg.backup.interval_hours, 6); | |
| 564 | + | |
| 565 | + std::fs::write(&path, format!("{DEV_DEFAULTS}\n[backup]\nkeep = 0\n")).unwrap(); | |
| 566 | + assert!(matches!(Config::load(&path), Err(Error::Config(_)))); | |
| 567 | + std::fs::write(&path, format!("{DEV_DEFAULTS}\n[backup]\nbogus = 1\n")).unwrap(); | |
| 568 | + assert!(matches!(Config::load(&path), Err(Error::Config(_)))); | |
| 569 | + } | |
| 570 | +} |
crates/forge-core/src/discussions.rs| @@ -0,0 +1,158 @@ | ||
| 1 | +//! Discussion types: a per-repo forum next to issues. Storage lives in `store/discussions.rs`. | |
| 2 | +//! Titles and bodies are checked with the issue validators, since the limits are the same. | |
| 3 | + | |
| 4 | +use crate::error::{Error, Result}; | |
| 5 | + | |
| 6 | +/// Categories a discussion can be filed under. The `as_str` names are stored. | |
| 7 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 8 | +pub enum DiscussionCategory { | |
| 9 | + General, | |
| 10 | + Ideas, | |
| 11 | + Qa, | |
| 12 | + /// Only Maintain or higher may post here. | |
| 13 | + Announcements, | |
| 14 | +} | |
| 15 | + | |
| 16 | +/// Every category, in the order the chooser shows them. | |
| 17 | +pub const DISCUSSION_CATEGORIES: &[DiscussionCategory] = &[ | |
| 18 | + DiscussionCategory::General, | |
| 19 | + DiscussionCategory::Ideas, | |
| 20 | + DiscussionCategory::Qa, | |
| 21 | + DiscussionCategory::Announcements, | |
| 22 | +]; | |
| 23 | + | |
| 24 | +impl DiscussionCategory { | |
| 25 | + /// The stored and URL name. | |
| 26 | + pub fn as_str(self) -> &'static str { | |
| 27 | + match self { | |
| 28 | + DiscussionCategory::General => "general", | |
| 29 | + DiscussionCategory::Ideas => "ideas", | |
| 30 | + DiscussionCategory::Qa => "qa", | |
| 31 | + DiscussionCategory::Announcements => "announcements", | |
| 32 | + } | |
| 33 | + } | |
| 34 | + | |
| 35 | + /// The label people read. | |
| 36 | + pub fn label(self) -> &'static str { | |
| 37 | + match self { | |
| 38 | + DiscussionCategory::General => "General", | |
| 39 | + DiscussionCategory::Ideas => "Ideas", | |
| 40 | + DiscussionCategory::Qa => "Q&A", | |
| 41 | + DiscussionCategory::Announcements => "Announcements", | |
| 42 | + } | |
| 43 | + } | |
| 44 | + | |
| 45 | + /// The category with this stored name, if any. | |
| 46 | + pub fn parse(name: &str) -> Option<DiscussionCategory> { | |
| 47 | + DISCUSSION_CATEGORIES | |
| 48 | + .iter() | |
| 49 | + .copied() | |
| 50 | + .find(|c| c.as_str() == name) | |
| 51 | + } | |
| 52 | +} | |
| 53 | + | |
| 54 | +/// One discussion. `answer_comment_id` is set only in Q&A, and names the accepted reply. | |
| 55 | +#[derive(Debug, Clone, PartialEq, Eq)] | |
| 56 | +pub struct Discussion { | |
| 57 | + pub id: String, | |
| 58 | + pub repo_id: String, | |
| 59 | + pub number: i64, | |
| 60 | + pub category: DiscussionCategory, | |
| 61 | + pub title: String, | |
| 62 | + pub body: String, | |
| 63 | + pub author_id: String, | |
| 64 | + pub answer_comment_id: Option<String>, | |
| 65 | + pub locked: bool, | |
| 66 | + pub comment_count: i64, | |
| 67 | + pub created_at: String, | |
| 68 | + pub updated_at: String, | |
| 69 | +} | |
| 70 | + | |
| 71 | +/// A discussion with its author's username, for the list. | |
| 72 | +#[derive(Debug, Clone, PartialEq, Eq)] | |
| 73 | +pub struct DiscussionListItem { | |
| 74 | + pub discussion: Discussion, | |
| 75 | + pub author: String, | |
| 76 | +} | |
| 77 | + | |
| 78 | +/// Why a discussion is in a user's inbox. The `as_str` names are stored in `discussion_notifications`. | |
| 79 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 80 | +pub enum DiscussionReason { | |
| 81 | + /// The user was @mentioned in the discussion or one of its comments. | |
| 82 | + Mention, | |
| 83 | + /// Someone commented on the user's discussion. | |
| 84 | + Reply, | |
| 85 | +} | |
| 86 | + | |
| 87 | +impl DiscussionReason { | |
| 88 | + /// The stored name. | |
| 89 | + pub fn as_str(self) -> &'static str { | |
| 90 | + match self { | |
| 91 | + DiscussionReason::Mention => "mention", | |
| 92 | + DiscussionReason::Reply => "reply", | |
| 93 | + } | |
| 94 | + } | |
| 95 | + | |
| 96 | + /// The reason with this stored name, if any. | |
| 97 | + pub fn parse(name: &str) -> Option<DiscussionReason> { | |
| 98 | + match name { | |
| 99 | + "mention" => Some(DiscussionReason::Mention), | |
| 100 | + "reply" => Some(DiscussionReason::Reply), | |
| 101 | + _ => None, | |
| 102 | + } | |
| 103 | + } | |
| 104 | +} | |
| 105 | + | |
| 106 | +/// One inbox row for a discussion, joined with its repo and title for the notifications page. | |
| 107 | +#[derive(Debug, Clone, PartialEq, Eq)] | |
| 108 | +pub struct DiscussionNotification { | |
| 109 | + pub discussion_id: String, | |
| 110 | + pub repo_id: String, | |
| 111 | + pub repo_owner: String, | |
| 112 | + pub repo_name: String, | |
| 113 | + pub number: i64, | |
| 114 | + pub title: String, | |
| 115 | + pub reason: DiscussionReason, | |
| 116 | + /// Username of the last person who acted, if the account still exists. | |
| 117 | + pub last_actor_name: Option<String>, | |
| 118 | + pub unread: bool, | |
| 119 | + pub updated_at: String, | |
| 120 | +} | |
| 121 | + | |
| 122 | +/// One comment. A top-level comment has no `parent_id`; a reply names a top-level comment of the | |
| 123 | +/// same discussion. A deleted comment has `deleted` set and an empty body. | |
| 124 | +#[derive(Debug, Clone, PartialEq, Eq)] | |
| 125 | +pub struct DiscussionComment { | |
| 126 | + pub id: String, | |
| 127 | + pub discussion_id: String, | |
| 128 | + pub parent_id: Option<String>, | |
| 129 | + pub author_id: String, | |
| 130 | + pub body: String, | |
| 131 | + pub created_at: String, | |
| 132 | + pub updated_at: String, | |
| 133 | + pub deleted: bool, | |
| 134 | +} | |
| 135 | + | |
| 136 | +/// Filters and paging for the discussion list. `category: None` lists every category. | |
| 137 | +#[derive(Debug, Clone, PartialEq, Eq)] | |
| 138 | +pub struct DiscussionFilter { | |
| 139 | + pub category: Option<DiscussionCategory>, | |
| 140 | + pub page: u32, | |
| 141 | + pub per_page: u32, | |
| 142 | +} | |
| 143 | + | |
| 144 | +/// Validates a discussion title (trimmed, returned) and body. | |
| 145 | +pub fn validate_discussion(title: &str, body: &str) -> Result<String> { | |
| 146 | + let title = crate::issues::validate_title(title)?; | |
| 147 | + crate::issues::validate_body(body)?; | |
| 148 | + Ok(title) | |
| 149 | +} | |
| 150 | + | |
| 151 | +/// Validates a comment body: not blank, and within the body limit. | |
| 152 | +pub fn validate_comment(body: &str) -> Result<()> { | |
| 153 | + crate::issues::validate_body(body)?; | |
| 154 | + if body.trim().is_empty() { | |
| 155 | + return Err(Error::Invalid("comment is empty".into())); | |
| 156 | + } | |
| 157 | + Ok(()) | |
| 158 | +} |
crates/forge-core/src/error.rs| @@ -0,0 +1,29 @@ | ||
| 1 | +//! The error type shared across forge crates. | |
| 2 | + | |
| 3 | +#[derive(Debug, thiserror::Error)] | |
| 4 | +pub enum Error { | |
| 5 | + #[error("io error: {0}")] | |
| 6 | + Io(#[from] std::io::Error), | |
| 7 | + #[error("database error: {0}")] | |
| 8 | + Database(String), | |
| 9 | + #[error("migration error: {0}")] | |
| 10 | + Migration(String), | |
| 11 | + #[error("config error: {0}")] | |
| 12 | + Config(String), | |
| 13 | + #[error("crypto error: {0}")] | |
| 14 | + Crypto(String), | |
| 15 | + #[error("invalid name: {0}")] | |
| 16 | + InvalidName(String), | |
| 17 | + #[error("git error: {0}")] | |
| 18 | + Git(String), | |
| 19 | + #[error("not found")] | |
| 20 | + NotFound, | |
| 21 | + #[error("conflict: {0}")] | |
| 22 | + Conflict(String), | |
| 23 | + #[error("forbidden: {0}")] | |
| 24 | + Forbidden(String), | |
| 25 | + #[error("invalid value: {0}")] | |
| 26 | + Invalid(String), | |
| 27 | +} | |
| 28 | + | |
| 29 | +pub type Result<T> = std::result::Result<T, Error>; |
crates/forge-core/src/issue_refs.rs| @@ -0,0 +1,219 @@ | ||
| 1 | +//! Parsing of issue references (`#12`) and closing keywords (`fixes #12`) in free text. | |
| 2 | +//! Hand-written scanner, no regex. Pure, no IO. | |
| 3 | + | |
| 4 | +use std::ops::Range; | |
| 5 | + | |
| 6 | +/// Keywords that close an issue when followed by a `#N` reference. Compared case-insensitively. | |
| 7 | +const CLOSING_KEYWORDS: [&str; 9] = [ | |
| 8 | + "close", "closes", "closed", "fix", "fixes", "fixed", "resolve", "resolves", "resolved", | |
| 9 | +]; | |
| 10 | + | |
| 11 | +/// Maximum number of digits in an issue number. | |
| 12 | +const MAX_DIGITS: usize = 9; | |
| 13 | + | |
| 14 | +/// Byte range (including the `#`) and value of every valid `#N` reference in `text`, in order. | |
| 15 | +/// Duplicates are kept. Used to link references in rendered markdown with the same rules. | |
| 16 | +pub fn issue_ref_spans(text: &str) -> Vec<(Range<usize>, i64)> { | |
| 17 | + scan_refs(text) | |
| 18 | +} | |
| 19 | + | |
| 20 | +/// Every `#N` reference in `text`, deduplicated, in order of first appearance. | |
| 21 | +/// | |
| 22 | +/// A reference is `#` followed by 1 to 9 digits. It is not recognised when the `#` is preceded by | |
| 23 | +/// an alphanumeric char, `&` or `/`, or when the digits are followed by an alphanumeric char. | |
| 24 | +/// `#0` is not an issue number and is ignored. | |
| 25 | +pub fn find_issue_refs(text: &str) -> Vec<i64> { | |
| 26 | + let mut out: Vec<i64> = Vec::new(); | |
| 27 | + for (_, n) in scan_refs(text) { | |
| 28 | + if !out.contains(&n) { | |
| 29 | + out.push(n); | |
| 30 | + } | |
| 31 | + } | |
| 32 | + out | |
| 33 | +} | |
| 34 | + | |
| 35 | +/// Issue numbers closed by closing keywords in `text`, deduplicated, in order. | |
| 36 | +/// | |
| 37 | +/// The keyword (case-insensitive, whole word) must directly precede the reference, optionally | |
| 38 | +/// separated by a `:`, with at least one whitespace char between the keyword (or colon) and `#`. | |
| 39 | +/// Example: `"Fixes: #3, closes #4"` gives `[3, 4]`; `"unfixes #2"` gives nothing. | |
| 40 | +pub fn find_closing_refs(text: &str) -> Vec<i64> { | |
| 41 | + let mut out: Vec<i64> = Vec::new(); | |
| 42 | + for (range, n) in scan_refs(text) { | |
| 43 | + if !preceded_by_closing_keyword(&text[..range.start]) { | |
| 44 | + continue; | |
| 45 | + } | |
| 46 | + if !out.contains(&n) { | |
| 47 | + out.push(n); | |
| 48 | + } | |
| 49 | + } | |
| 50 | + out | |
| 51 | +} | |
| 52 | + | |
| 53 | +/// Byte range of each valid `#N` reference and its value, in order of appearance. | |
| 54 | +fn scan_refs(text: &str) -> Vec<(Range<usize>, i64)> { | |
| 55 | + let bytes = text.as_bytes(); | |
| 56 | + let mut refs = Vec::new(); | |
| 57 | + let mut i = 0; | |
| 58 | + while i < bytes.len() { | |
| 59 | + if bytes[i] != b'#' { | |
| 60 | + i += 1; | |
| 61 | + continue; | |
| 62 | + } | |
| 63 | + let hash = i; | |
| 64 | + i += 1; | |
| 65 | + let digits_start = i; | |
| 66 | + while i < bytes.len() && bytes[i].is_ascii_digit() { | |
| 67 | + i += 1; | |
| 68 | + } | |
| 69 | + let digits = &bytes[digits_start..i]; | |
| 70 | + if digits.is_empty() || digits.len() > MAX_DIGITS { | |
| 71 | + continue; | |
| 72 | + } | |
| 73 | + if let Some(next) = text[i..].chars().next() | |
| 74 | + && next.is_alphanumeric() | |
| 75 | + { | |
| 76 | + continue; | |
| 77 | + } | |
| 78 | + if let Some(prev) = text[..hash].chars().next_back() | |
| 79 | + && (prev.is_alphanumeric() || prev == '&' || prev == '/') | |
| 80 | + { | |
| 81 | + continue; | |
| 82 | + } | |
| 83 | + let n = digits | |
| 84 | + .iter() | |
| 85 | + .fold(0i64, |acc, d| acc * 10 + i64::from(d - b'0')); | |
| 86 | + if n == 0 { | |
| 87 | + continue; | |
| 88 | + } | |
| 89 | + refs.push((hash..i, n)); | |
| 90 | + } | |
| 91 | + refs | |
| 92 | +} | |
| 93 | + | |
| 94 | +/// True when `before` (the text preceding a `#N` reference) ends with a closing keyword, | |
| 95 | +/// an optional `:`, and at least one whitespace char. | |
| 96 | +fn preceded_by_closing_keyword(before: &str) -> bool { | |
| 97 | + let trimmed = before.trim_end(); | |
| 98 | + if trimmed.len() == before.len() { | |
| 99 | + // No whitespace between the keyword and the reference. | |
| 100 | + return false; | |
| 101 | + } | |
| 102 | + let word_part = trimmed.strip_suffix(':').unwrap_or(trimmed); | |
| 103 | + let lower = word_part.to_ascii_lowercase(); | |
| 104 | + CLOSING_KEYWORDS.iter().any(|kw| { | |
| 105 | + lower.ends_with(kw) | |
| 106 | + && word_part[..word_part.len() - kw.len()] | |
| 107 | + .chars() | |
| 108 | + .next_back() | |
| 109 | + .is_none_or(|c| !c.is_alphanumeric()) | |
| 110 | + }) | |
| 111 | +} | |
| 112 | + | |
| 113 | +#[cfg(test)] | |
| 114 | +mod tests { | |
| 115 | + use super::*; | |
| 116 | + | |
| 117 | + #[test] | |
| 118 | + fn plain_reference() { | |
| 119 | + assert_eq!(find_issue_refs("fixes #12"), vec![12]); | |
| 120 | + } | |
| 121 | + | |
| 122 | + #[test] | |
| 123 | + fn multiple_references_in_order() { | |
| 124 | + assert_eq!(find_issue_refs("Fixes: #3, closes #4"), vec![3, 4]); | |
| 125 | + } | |
| 126 | + | |
| 127 | + #[test] | |
| 128 | + fn prefix_before_hash_is_not_a_reference() { | |
| 129 | + assert!(find_issue_refs("prefix#5").is_empty()); | |
| 130 | + } | |
| 131 | + | |
| 132 | + #[test] | |
| 133 | + fn html_entity_is_not_a_reference() { | |
| 134 | + assert!(find_issue_refs("'").is_empty()); | |
| 135 | + } | |
| 136 | + | |
| 137 | + #[test] | |
| 138 | + fn slash_before_hash_is_not_a_reference() { | |
| 139 | + assert!(find_issue_refs("a/#7").is_empty()); | |
| 140 | + } | |
| 141 | + | |
| 142 | + #[test] | |
| 143 | + fn leading_zeros_are_parsed() { | |
| 144 | + assert_eq!(find_issue_refs("#0012"), vec![12]); | |
| 145 | + } | |
| 146 | + | |
| 147 | + #[test] | |
| 148 | + fn ten_digits_is_not_a_reference() { | |
| 149 | + assert!(find_issue_refs("#1234567890").is_empty()); | |
| 150 | + } | |
| 151 | + | |
| 152 | + #[test] | |
| 153 | + fn nine_digits_is_a_reference() { | |
| 154 | + assert_eq!(find_issue_refs("#123456789"), vec![123_456_789]); | |
| 155 | + } | |
| 156 | + | |
| 157 | + #[test] | |
| 158 | + fn zero_is_not_a_reference() { | |
| 159 | + assert!(find_issue_refs("#0").is_empty()); | |
| 160 | + } | |
| 161 | + | |
| 162 | + #[test] | |
| 163 | + fn parentheses_are_allowed_around_reference() { | |
| 164 | + assert_eq!(find_issue_refs("(#8)"), vec![8]); | |
| 165 | + } | |
| 166 | + | |
| 167 | + #[test] | |
| 168 | + fn trailing_punctuation_is_allowed() { | |
| 169 | + assert_eq!(find_issue_refs("#9."), vec![9]); | |
| 170 | + } | |
| 171 | + | |
| 172 | + #[test] | |
| 173 | + fn alphanumeric_suffix_is_not_a_reference() { | |
| 174 | + assert!(find_issue_refs("#12abc").is_empty()); | |
| 175 | + } | |
| 176 | + | |
| 177 | + #[test] | |
| 178 | + fn duplicates_are_removed() { | |
| 179 | + assert_eq!(find_issue_refs("#2 and #1 and #2"), vec![2, 1]); | |
| 180 | + assert_eq!(find_closing_refs("fixes #2, closes #2"), vec![2]); | |
| 181 | + } | |
| 182 | + | |
| 183 | + #[test] | |
| 184 | + fn unfixes_is_not_closing() { | |
| 185 | + assert!(find_closing_refs("unfixes #2").is_empty()); | |
| 186 | + assert_eq!(find_issue_refs("unfixes #2"), vec![2]); | |
| 187 | + } | |
| 188 | + | |
| 189 | + #[test] | |
| 190 | + fn fixed_is_closing() { | |
| 191 | + assert_eq!(find_closing_refs("fixed #2"), vec![2]); | |
| 192 | + } | |
| 193 | + | |
| 194 | + #[test] | |
| 195 | + fn all_keywords_are_case_insensitive() { | |
| 196 | + for kw in [ | |
| 197 | + "close", "closes", "closed", "fix", "fixes", "fixed", "resolve", "resolves", "resolved", | |
| 198 | + ] { | |
| 199 | + let text = format!("{}: #5", kw.to_uppercase()); | |
| 200 | + assert_eq!(find_closing_refs(&text), vec![5], "{kw}"); | |
| 201 | + } | |
| 202 | + } | |
| 203 | + | |
| 204 | + #[test] | |
| 205 | + fn keyword_needs_whitespace_before_hash() { | |
| 206 | + assert!(find_closing_refs("fixes#2").is_empty()); | |
| 207 | + assert!(find_closing_refs("fixes:#2").is_empty()); | |
| 208 | + } | |
| 209 | + | |
| 210 | + #[test] | |
| 211 | + fn plain_reference_is_not_closing() { | |
| 212 | + assert!(find_closing_refs("see #2").is_empty()); | |
| 213 | + } | |
| 214 | + | |
| 215 | + #[test] | |
| 216 | + fn keyword_must_be_a_whole_word() { | |
| 217 | + assert!(find_closing_refs("prefixes #2").is_empty()); | |
| 218 | + } | |
| 219 | +} |
crates/forge-core/src/issues.rs| @@ -0,0 +1,369 @@ | ||
| 1 | +//! Issue, comment, label and milestone types, and their validation. Stored shapes are in | |
| 2 | +//! migrations/0004_issues.sql; the store functions live in store/issues.rs and store/labels.rs. | |
| 3 | + | |
| 4 | +use crate::error::{Error, Result}; | |
| 5 | +use crate::model::Id; | |
| 6 | + | |
| 7 | +/// Longest accepted issue title, in characters. | |
| 8 | +pub const MAX_TITLE: usize = 256; | |
| 9 | +/// Largest accepted issue, comment or edit body, in bytes. | |
| 10 | +pub const MAX_BODY: usize = 65536; | |
| 11 | +/// Longest accepted label name, in characters. | |
| 12 | +pub const MAX_LABEL_NAME: usize = 50; | |
| 13 | +/// Most assignees one issue can have. | |
| 14 | +pub const MAX_ASSIGNEES: usize = 10; | |
| 15 | + | |
| 16 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 17 | +pub enum IssueState { | |
| 18 | + Open, | |
| 19 | + Closed, | |
| 20 | +} | |
| 21 | + | |
| 22 | +impl IssueState { | |
| 23 | + pub fn as_str(&self) -> &'static str { | |
| 24 | + match self { | |
| 25 | + IssueState::Open => "open", | |
| 26 | + IssueState::Closed => "closed", | |
| 27 | + } | |
| 28 | + } | |
| 29 | +} | |
| 30 | + | |
| 31 | +impl std::str::FromStr for IssueState { | |
| 32 | + type Err = Error; | |
| 33 | + | |
| 34 | + fn from_str(s: &str) -> Result<Self> { | |
| 35 | + match s { | |
| 36 | + "open" => Ok(IssueState::Open), | |
| 37 | + "closed" => Ok(IssueState::Closed), | |
| 38 | + other => Err(Error::Invalid(format!("issue state {other:?}"))), | |
| 39 | + } | |
| 40 | + } | |
| 41 | +} | |
| 42 | + | |
| 43 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 44 | +pub enum StateReason { | |
| 45 | + Completed, | |
| 46 | + NotPlanned, | |
| 47 | +} | |
| 48 | + | |
| 49 | +impl StateReason { | |
| 50 | + pub fn as_str(&self) -> &'static str { | |
| 51 | + match self { | |
| 52 | + StateReason::Completed => "completed", | |
| 53 | + StateReason::NotPlanned => "not_planned", | |
| 54 | + } | |
| 55 | + } | |
| 56 | +} | |
| 57 | + | |
| 58 | +impl std::str::FromStr for StateReason { | |
| 59 | + type Err = Error; | |
| 60 | + | |
| 61 | + fn from_str(s: &str) -> Result<Self> { | |
| 62 | + match s { | |
| 63 | + "completed" => Ok(StateReason::Completed), | |
| 64 | + "not_planned" => Ok(StateReason::NotPlanned), | |
| 65 | + other => Err(Error::Invalid(format!("state reason {other:?}"))), | |
| 66 | + } | |
| 67 | + } | |
| 68 | +} | |
| 69 | + | |
| 70 | +#[derive(Debug, Clone, PartialEq)] | |
| 71 | +pub struct Issue { | |
| 72 | + pub id: Id, | |
| 73 | + pub repo_id: Id, | |
| 74 | + pub number: i64, | |
| 75 | + pub title: String, | |
| 76 | + pub body: String, | |
| 77 | + pub state: IssueState, | |
| 78 | + pub state_reason: Option<StateReason>, | |
| 79 | + pub author_id: Id, | |
| 80 | + pub milestone_id: Option<Id>, | |
| 81 | + pub comment_count: i64, | |
| 82 | + pub created_at: String, | |
| 83 | + pub updated_at: String, | |
| 84 | + pub closed_at: Option<String>, | |
| 85 | + /// Locked conversations accept writes only from collaborators with Triage or higher. | |
| 86 | + pub locked: bool, | |
| 87 | + /// One of `LOCK_REASONS`, or `None` when no reason was given. | |
| 88 | + pub lock_reason: Option<String>, | |
| 89 | +} | |
| 90 | + | |
| 91 | +/// Reasons accepted when locking a conversation. | |
| 92 | +pub const LOCK_REASONS: &[&str] = &["off_topic", "too_heated", "resolved", "spam"]; | |
| 93 | + | |
| 94 | +#[derive(Debug, Clone, PartialEq)] | |
| 95 | +pub struct Comment { | |
| 96 | + pub id: Id, | |
| 97 | + pub issue_id: Id, | |
| 98 | + pub author_id: Id, | |
| 99 | + pub body: String, | |
| 100 | + pub created_at: String, | |
| 101 | + pub updated_at: String, | |
| 102 | + pub edited: bool, | |
| 103 | +} | |
| 104 | + | |
| 105 | +#[derive(Debug, Clone, PartialEq)] | |
| 106 | +pub struct IssueEvent { | |
| 107 | + pub id: Id, | |
| 108 | + pub issue_id: Id, | |
| 109 | + /// `None` for system events. | |
| 110 | + pub actor_id: Option<Id>, | |
| 111 | + pub kind: String, | |
| 112 | + pub data: serde_json::Value, | |
| 113 | + pub created_at: String, | |
| 114 | +} | |
| 115 | + | |
| 116 | +#[derive(Debug, Clone, PartialEq)] | |
| 117 | +pub struct Label { | |
| 118 | + pub id: Id, | |
| 119 | + pub repo_id: Id, | |
| 120 | + pub name: String, | |
| 121 | + /// Lowercase `#rrggbb`. | |
| 122 | + pub color: String, | |
| 123 | + pub description: String, | |
| 124 | +} | |
| 125 | + | |
| 126 | +#[derive(Debug, Clone, PartialEq)] | |
| 127 | +pub struct Milestone { | |
| 128 | + pub id: Id, | |
| 129 | + pub repo_id: Id, | |
| 130 | + pub title: String, | |
| 131 | + pub description: String, | |
| 132 | + /// `YYYY-MM-DD` or `None`. | |
| 133 | + pub due_on: Option<String>, | |
| 134 | + pub state: IssueState, | |
| 135 | + pub created_at: String, | |
| 136 | + pub closed_at: Option<String>, | |
| 137 | + /// Counts filled by list/get queries. | |
| 138 | + pub open_issues: i64, | |
| 139 | + pub closed_issues: i64, | |
| 140 | +} | |
| 141 | + | |
| 142 | +#[derive(Debug, Clone, PartialEq)] | |
| 143 | +pub struct IssueFilter { | |
| 144 | + pub state: Option<IssueState>, | |
| 145 | + /// Label name. | |
| 146 | + pub label: Option<String>, | |
| 147 | + /// Milestone title. | |
| 148 | + pub milestone: Option<String>, | |
| 149 | + /// Author username. | |
| 150 | + pub author: Option<String>, | |
| 151 | + /// Assignee username. | |
| 152 | + pub assignee: Option<String>, | |
| 153 | + /// Substring of the title, case-insensitive. | |
| 154 | + pub query: Option<String>, | |
| 155 | + pub sort: IssueSort, | |
| 156 | + /// 1-based. | |
| 157 | + pub page: u32, | |
| 158 | + pub per_page: u32, | |
| 159 | +} | |
| 160 | + | |
| 161 | +impl Default for IssueFilter { | |
| 162 | + fn default() -> Self { | |
| 163 | + IssueFilter { | |
| 164 | + state: Some(IssueState::Open), | |
| 165 | + label: None, | |
| 166 | + milestone: None, | |
| 167 | + author: None, | |
| 168 | + assignee: None, | |
| 169 | + query: None, | |
| 170 | + sort: IssueSort::Newest, | |
| 171 | + page: 1, | |
| 172 | + per_page: 25, | |
| 173 | + } | |
| 174 | + } | |
| 175 | +} | |
| 176 | + | |
| 177 | +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] | |
| 178 | +pub enum IssueSort { | |
| 179 | + #[default] | |
| 180 | + Newest, | |
| 181 | + Oldest, | |
| 182 | + RecentlyUpdated, | |
| 183 | +} | |
| 184 | + | |
| 185 | +impl IssueSort { | |
| 186 | + /// The value used in the `sort` query parameter. | |
| 187 | + pub fn as_str(&self) -> &'static str { | |
| 188 | + match self { | |
| 189 | + IssueSort::Newest => "newest", | |
| 190 | + IssueSort::Oldest => "oldest", | |
| 191 | + IssueSort::RecentlyUpdated => "updated", | |
| 192 | + } | |
| 193 | + } | |
| 194 | +} | |
| 195 | + | |
| 196 | +impl std::str::FromStr for IssueSort { | |
| 197 | + type Err = Error; | |
| 198 | + | |
| 199 | + fn from_str(s: &str) -> Result<Self> { | |
| 200 | + match s { | |
| 201 | + "newest" => Ok(IssueSort::Newest), | |
| 202 | + "oldest" => Ok(IssueSort::Oldest), | |
| 203 | + "updated" => Ok(IssueSort::RecentlyUpdated), | |
| 204 | + other => Err(Error::Invalid(format!("sort {other:?}"))), | |
| 205 | + } | |
| 206 | + } | |
| 207 | +} | |
| 208 | + | |
| 209 | +#[derive(Debug, Clone, PartialEq)] | |
| 210 | +pub struct IssueListItem { | |
| 211 | + pub issue: Issue, | |
| 212 | + /// Display username of the author. | |
| 213 | + pub author: String, | |
| 214 | + pub labels: Vec<Label>, | |
| 215 | + /// Display usernames of the assignees. | |
| 216 | + pub assignees: Vec<String>, | |
| 217 | +} | |
| 218 | + | |
| 219 | +/// Trimmed title: non-empty, at most `MAX_TITLE` characters, no control characters. | |
| 220 | +pub fn validate_title(t: &str) -> Result<String> { | |
| 221 | + let title = t.trim(); | |
| 222 | + if title.is_empty() { | |
| 223 | + return Err(Error::Invalid("title is empty".into())); | |
| 224 | + } | |
| 225 | + if title.chars().count() > MAX_TITLE { | |
| 226 | + return Err(Error::Invalid(format!( | |
| 227 | + "title is longer than {MAX_TITLE} characters" | |
| 228 | + ))); | |
| 229 | + } | |
| 230 | + if title.chars().any(char::is_control) { | |
| 231 | + return Err(Error::Invalid("title contains control characters".into())); | |
| 232 | + } | |
| 233 | + Ok(title.to_string()) | |
| 234 | +} | |
| 235 | + | |
| 236 | +/// Body: at most `MAX_BODY` bytes. An empty body is allowed. | |
| 237 | +pub fn validate_body(b: &str) -> Result<()> { | |
| 238 | + if b.len() > MAX_BODY { | |
| 239 | + return Err(Error::Invalid(format!( | |
| 240 | + "body is longer than {MAX_BODY} bytes" | |
| 241 | + ))); | |
| 242 | + } | |
| 243 | + Ok(()) | |
| 244 | +} | |
| 245 | + | |
| 246 | +/// Returns `(trimmed name, normalized color)`. The name is 1..=50 characters with no control | |
| 247 | +/// characters. The color is `#rgb` or `#rrggbb` in any case and comes back as lowercase `#rrggbb`. | |
| 248 | +pub fn validate_label(name: &str, color: &str) -> Result<(String, String)> { | |
| 249 | + let name = name.trim(); | |
| 250 | + let len = name.chars().count(); | |
| 251 | + if len == 0 || len > MAX_LABEL_NAME { | |
| 252 | + return Err(Error::Invalid(format!( | |
| 253 | + "label name must be 1 to {MAX_LABEL_NAME} characters" | |
| 254 | + ))); | |
| 255 | + } | |
| 256 | + if name.chars().any(char::is_control) { | |
| 257 | + return Err(Error::Invalid( | |
| 258 | + "label name contains control characters".into(), | |
| 259 | + )); | |
| 260 | + } | |
| 261 | + let color = normalize_color(color) | |
| 262 | + .ok_or_else(|| Error::Invalid(format!("label color {color:?} is not #rgb or #rrggbb")))?; | |
| 263 | + Ok((name.to_string(), color)) | |
| 264 | +} | |
| 265 | + | |
| 266 | +fn normalize_color(color: &str) -> Option<String> { | |
| 267 | + let hex = color.strip_prefix('#')?; | |
| 268 | + if !hex.chars().all(|c| c.is_ascii_hexdigit()) { | |
| 269 | + return None; | |
| 270 | + } | |
| 271 | + let lower = hex.to_ascii_lowercase(); | |
| 272 | + match lower.len() { | |
| 273 | + 3 => { | |
| 274 | + let mut out = String::with_capacity(7); | |
| 275 | + out.push('#'); | |
| 276 | + for c in lower.chars() { | |
| 277 | + out.push(c); | |
| 278 | + out.push(c); | |
| 279 | + } | |
| 280 | + Some(out) | |
| 281 | + } | |
| 282 | + 6 => Some(format!("#{lower}")), | |
| 283 | + _ => None, | |
| 284 | + } | |
| 285 | +} | |
| 286 | + | |
| 287 | +#[cfg(test)] | |
| 288 | +mod tests { | |
| 289 | + use super::*; | |
| 290 | + | |
| 291 | + #[test] | |
| 292 | + fn title_is_trimmed_and_checked() { | |
| 293 | + assert_eq!(validate_title(" Fix it ").unwrap(), "Fix it"); | |
| 294 | + assert!(validate_title(" ").is_err()); | |
| 295 | + assert!(validate_title("").is_err()); | |
| 296 | + assert!(validate_title("a\tb").is_err()); | |
| 297 | + assert!(validate_title("a\nb").is_err()); | |
| 298 | + assert!(validate_title(&"x".repeat(MAX_TITLE)).is_ok()); | |
| 299 | + assert!(validate_title(&"x".repeat(MAX_TITLE + 1)).is_err()); | |
| 300 | + // Counted in characters, not bytes. | |
| 301 | + assert!(validate_title(&"é".repeat(MAX_TITLE)).is_ok()); | |
| 302 | + assert!(validate_title(&"é".repeat(MAX_TITLE + 1)).is_err()); | |
| 303 | + } | |
| 304 | + | |
| 305 | + #[test] | |
| 306 | + fn body_is_checked_in_bytes() { | |
| 307 | + assert!(validate_body("").is_ok()); | |
| 308 | + assert!(validate_body(&"x".repeat(MAX_BODY)).is_ok()); | |
| 309 | + assert!(validate_body(&"x".repeat(MAX_BODY + 1)).is_err()); | |
| 310 | + // 2 bytes per char: 32768 chars is exactly MAX_BODY bytes. | |
| 311 | + assert!(validate_body(&"é".repeat(MAX_BODY / 2)).is_ok()); | |
| 312 | + assert!(validate_body(&"é".repeat(MAX_BODY / 2 + 1)).is_err()); | |
| 313 | + } | |
| 314 | + | |
| 315 | + #[test] | |
| 316 | + fn label_name_and_color_are_normalized() { | |
| 317 | + assert_eq!( | |
| 318 | + validate_label(" bug ", "#FF0000").unwrap(), | |
| 319 | + ("bug".to_string(), "#ff0000".to_string()) | |
| 320 | + ); | |
| 321 | + assert_eq!( | |
| 322 | + validate_label("x", "#AbC").unwrap(), | |
| 323 | + ("x".to_string(), "#aabbcc".to_string()) | |
| 324 | + ); | |
| 325 | + assert!(validate_label("", "#fff").is_err()); | |
| 326 | + assert!(validate_label(" ", "#fff").is_err()); | |
| 327 | + assert!(validate_label(&"a".repeat(MAX_LABEL_NAME), "#fff").is_ok()); | |
| 328 | + assert!(validate_label(&"a".repeat(MAX_LABEL_NAME + 1), "#fff").is_err()); | |
| 329 | + assert!(validate_label("a\u{7}", "#fff").is_err()); | |
| 330 | + } | |
| 331 | + | |
| 332 | + #[test] | |
| 333 | + fn label_color_must_be_hex() { | |
| 334 | + for bad in [ | |
| 335 | + "", "fff", "#ff", "#ffff", "#fffffff", "#gggggg", "red", "# fff", "#ff00 0", | |
| 336 | + ] { | |
| 337 | + assert!(validate_label("x", bad).is_err(), "{bad:?} accepted"); | |
| 338 | + } | |
| 339 | + } | |
| 340 | + | |
| 341 | + #[test] | |
| 342 | + fn enums_round_trip() { | |
| 343 | + for s in [IssueState::Open, IssueState::Closed] { | |
| 344 | + assert_eq!(s.as_str().parse::<IssueState>().ok(), Some(s)); | |
| 345 | + } | |
| 346 | + assert!("Open".parse::<IssueState>().is_err()); | |
| 347 | + for r in [StateReason::Completed, StateReason::NotPlanned] { | |
| 348 | + assert_eq!(r.as_str().parse::<StateReason>().ok(), Some(r)); | |
| 349 | + } | |
| 350 | + assert!("duplicate".parse::<StateReason>().is_err()); | |
| 351 | + for s in [ | |
| 352 | + IssueSort::Newest, | |
| 353 | + IssueSort::Oldest, | |
| 354 | + IssueSort::RecentlyUpdated, | |
| 355 | + ] { | |
| 356 | + assert_eq!(s.as_str().parse::<IssueSort>().ok(), Some(s)); | |
| 357 | + } | |
| 358 | + assert!("bogus".parse::<IssueSort>().is_err()); | |
| 359 | + } | |
| 360 | + | |
| 361 | + #[test] | |
| 362 | + fn default_filter_is_open_first_page() { | |
| 363 | + let f = IssueFilter::default(); | |
| 364 | + assert_eq!(f.state, Some(IssueState::Open)); | |
| 365 | + assert_eq!(f.page, 1); | |
| 366 | + assert_eq!(f.per_page, 25); | |
| 367 | + assert_eq!(f.sort, IssueSort::Newest); | |
| 368 | + } | |
| 369 | +} |
crates/forge-core/src/lfs_token.rs| @@ -0,0 +1,216 @@ | ||
| 1 | +//! Short-lived signed tokens for Git LFS transfer actions (see docs/wave-b-interfaces.md). | |
| 2 | +//! | |
| 3 | +//! Token format: `"<expires>.<op>.<base64url-nopad(hmac_sha256)>"`, where the MAC covers | |
| 4 | +//! `"lfs\0" + repo_id + "\0" + op + "\0" + expires`. `op` is `download`, `upload` or `any`. | |
| 5 | + | |
| 6 | +use base64::prelude::{BASE64_URL_SAFE_NO_PAD, Engine as _}; | |
| 7 | +use hmac::{Hmac, KeyInit, Mac}; | |
| 8 | +use sha2::Sha256; | |
| 9 | +use subtle::ConstantTimeEq; | |
| 10 | + | |
| 11 | +type HmacSha256 = Hmac<Sha256>; | |
| 12 | + | |
| 13 | +const MAC_LEN: usize = 32; | |
| 14 | + | |
| 15 | +/// The operation a token grants. `Any` grants both download and upload. | |
| 16 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 17 | +pub enum LfsOp { | |
| 18 | + Download, | |
| 19 | + Upload, | |
| 20 | + Any, | |
| 21 | +} | |
| 22 | + | |
| 23 | +impl LfsOp { | |
| 24 | + pub fn as_str(self) -> &'static str { | |
| 25 | + match self { | |
| 26 | + LfsOp::Download => "download", | |
| 27 | + LfsOp::Upload => "upload", | |
| 28 | + LfsOp::Any => "any", | |
| 29 | + } | |
| 30 | + } | |
| 31 | + | |
| 32 | + fn parse(s: &str) -> Option<LfsOp> { | |
| 33 | + match s { | |
| 34 | + "download" => Some(LfsOp::Download), | |
| 35 | + "upload" => Some(LfsOp::Upload), | |
| 36 | + "any" => Some(LfsOp::Any), | |
| 37 | + _ => None, | |
| 38 | + } | |
| 39 | + } | |
| 40 | +} | |
| 41 | + | |
| 42 | +fn mac_for(key: &[u8; 32], repo_id: &str, op: &str, expires_unix: i64) -> HmacSha256 { | |
| 43 | + // HMAC accepts keys of any length, so construction with a 32-byte key cannot fail. | |
| 44 | + let mut mac = | |
| 45 | + <HmacSha256 as KeyInit>::new_from_slice(key).expect("HMAC accepts any key length"); | |
| 46 | + mac.update(b"lfs\0"); | |
| 47 | + mac.update(repo_id.as_bytes()); | |
| 48 | + mac.update(b"\0"); | |
| 49 | + mac.update(op.as_bytes()); | |
| 50 | + mac.update(b"\0"); | |
| 51 | + mac.update(expires_unix.to_string().as_bytes()); | |
| 52 | + mac | |
| 53 | +} | |
| 54 | + | |
| 55 | +/// Issues a token for `repo_id`, valid for `op` until `expires_unix` (seconds since the epoch). | |
| 56 | +pub fn issue(key: &[u8; 32], repo_id: &str, op: LfsOp, expires_unix: i64) -> String { | |
| 57 | + let tag = mac_for(key, repo_id, op.as_str(), expires_unix) | |
| 58 | + .finalize() | |
| 59 | + .into_bytes(); | |
| 60 | + format!( | |
| 61 | + "{expires_unix}.{}.{}", | |
| 62 | + op.as_str(), | |
| 63 | + BASE64_URL_SAFE_NO_PAD.encode(tag) | |
| 64 | + ) | |
| 65 | +} | |
| 66 | + | |
| 67 | +/// Verifies `token` for `repo_id` and the wanted operation at time `now_unix`. | |
| 68 | +/// | |
| 69 | +/// Rejects malformed input, expired tokens (`now >= expires`) and bad MACs. The MAC | |
| 70 | +/// comparison is constant time. An `Any` token satisfies `Download` and `Upload`; a | |
| 71 | +/// `Download` token never satisfies `Upload`, and only an `Any` token satisfies `Any`. | |
| 72 | +pub fn verify(key: &[u8; 32], token: &str, repo_id: &str, wanted: LfsOp, now_unix: i64) -> bool { | |
| 73 | + let mut parts = token.splitn(3, '.'); | |
| 74 | + let (Some(expires_str), Some(op_str), Some(mac_str)) = | |
| 75 | + (parts.next(), parts.next(), parts.next()) | |
| 76 | + else { | |
| 77 | + return false; | |
| 78 | + }; | |
| 79 | + // Strict canonical decimal: no sign, no leading zeros, no whitespace. | |
| 80 | + let Ok(expires) = expires_str.parse::<i64>() else { | |
| 81 | + return false; | |
| 82 | + }; | |
| 83 | + if expires.to_string() != expires_str { | |
| 84 | + return false; | |
| 85 | + } | |
| 86 | + if now_unix >= expires { | |
| 87 | + return false; | |
| 88 | + } | |
| 89 | + let Some(token_op) = LfsOp::parse(op_str) else { | |
| 90 | + return false; | |
| 91 | + }; | |
| 92 | + let satisfies = token_op == LfsOp::Any || token_op == wanted; | |
| 93 | + if !satisfies { | |
| 94 | + return false; | |
| 95 | + } | |
| 96 | + let Ok(tag) = BASE64_URL_SAFE_NO_PAD.decode(mac_str) else { | |
| 97 | + return false; | |
| 98 | + }; | |
| 99 | + if tag.len() != MAC_LEN { | |
| 100 | + return false; | |
| 101 | + } | |
| 102 | + let expected = mac_for(key, repo_id, op_str, expires) | |
| 103 | + .finalize() | |
| 104 | + .into_bytes(); | |
| 105 | + bool::from(expected.as_slice().ct_eq(&tag)) | |
| 106 | +} | |
| 107 | + | |
| 108 | +#[cfg(test)] | |
| 109 | +mod tests { | |
| 110 | + use super::*; | |
| 111 | + | |
| 112 | + const KEY: [u8; 32] = [7; 32]; | |
| 113 | + const OTHER_KEY: [u8; 32] = [9; 32]; | |
| 114 | + const NOW: i64 = 1_700_000_000; | |
| 115 | + | |
| 116 | + #[test] | |
| 117 | + fn round_trip_each_op() { | |
| 118 | + let dl = issue(&KEY, "repo-1", LfsOp::Download, NOW + 60); | |
| 119 | + assert!(verify(&KEY, &dl, "repo-1", LfsOp::Download, NOW)); | |
| 120 | + let ul = issue(&KEY, "repo-1", LfsOp::Upload, NOW + 60); | |
| 121 | + assert!(verify(&KEY, &ul, "repo-1", LfsOp::Upload, NOW)); | |
| 122 | + let any = issue(&KEY, "repo-1", LfsOp::Any, NOW + 60); | |
| 123 | + assert!(verify(&KEY, &any, "repo-1", LfsOp::Any, NOW)); | |
| 124 | + } | |
| 125 | + | |
| 126 | + #[test] | |
| 127 | + fn token_has_expected_shape() { | |
| 128 | + let token = issue(&KEY, "repo-1", LfsOp::Upload, NOW + 60); | |
| 129 | + let mut parts = token.splitn(3, '.'); | |
| 130 | + assert_eq!(parts.next().unwrap(), (NOW + 60).to_string()); | |
| 131 | + assert_eq!(parts.next().unwrap(), "upload"); | |
| 132 | + assert_eq!( | |
| 133 | + BASE64_URL_SAFE_NO_PAD | |
| 134 | + .decode(parts.next().unwrap()) | |
| 135 | + .unwrap() | |
| 136 | + .len(), | |
| 137 | + MAC_LEN | |
| 138 | + ); | |
| 139 | + } | |
| 140 | + | |
| 141 | + #[test] | |
| 142 | + fn expired_is_rejected() { | |
| 143 | + let token = issue(&KEY, "repo-1", LfsOp::Any, NOW); | |
| 144 | + assert!(!verify(&KEY, &token, "repo-1", LfsOp::Download, NOW)); | |
| 145 | + let token = issue(&KEY, "repo-1", LfsOp::Any, NOW + 1); | |
| 146 | + assert!(verify(&KEY, &token, "repo-1", LfsOp::Download, NOW)); | |
| 147 | + assert!(!verify(&KEY, &token, "repo-1", LfsOp::Download, NOW + 1)); | |
| 148 | + } | |
| 149 | + | |
| 150 | + #[test] | |
| 151 | + fn wrong_repo_is_rejected() { | |
| 152 | + let token = issue(&KEY, "repo-1", LfsOp::Any, NOW + 60); | |
| 153 | + assert!(!verify(&KEY, &token, "repo-2", LfsOp::Download, NOW)); | |
| 154 | + } | |
| 155 | + | |
| 156 | + #[test] | |
| 157 | + fn wrong_key_is_rejected() { | |
| 158 | + let token = issue(&KEY, "repo-1", LfsOp::Any, NOW + 60); | |
| 159 | + assert!(!verify(&OTHER_KEY, &token, "repo-1", LfsOp::Download, NOW)); | |
| 160 | + } | |
| 161 | + | |
| 162 | + #[test] | |
| 163 | + fn wrong_op_is_rejected() { | |
| 164 | + let dl = issue(&KEY, "repo-1", LfsOp::Download, NOW + 60); | |
| 165 | + assert!(!verify(&KEY, &dl, "repo-1", LfsOp::Upload, NOW)); | |
| 166 | + assert!(!verify(&KEY, &dl, "repo-1", LfsOp::Any, NOW)); | |
| 167 | + let ul = issue(&KEY, "repo-1", LfsOp::Upload, NOW + 60); | |
| 168 | + assert!(!verify(&KEY, &ul, "repo-1", LfsOp::Download, NOW)); | |
| 169 | + } | |
| 170 | + | |
| 171 | + #[test] | |
| 172 | + fn any_satisfies_download_and_upload() { | |
| 173 | + let any = issue(&KEY, "repo-1", LfsOp::Any, NOW + 60); | |
| 174 | + assert!(verify(&KEY, &any, "repo-1", LfsOp::Download, NOW)); | |
| 175 | + assert!(verify(&KEY, &any, "repo-1", LfsOp::Upload, NOW)); | |
| 176 | + } | |
| 177 | + | |
| 178 | + #[test] | |
| 179 | + fn tampering_is_rejected() { | |
| 180 | + let token = issue(&KEY, "repo-1", LfsOp::Download, NOW + 60); | |
| 181 | + // Upgrading the op in the clear must fail the MAC check. | |
| 182 | + let upgraded = token.replacen(".download.", ".any.", 1); | |
| 183 | + assert!(!verify(&KEY, &upgraded, "repo-1", LfsOp::Upload, NOW)); | |
| 184 | + // Extending expiry must fail too. | |
| 185 | + let (exp, rest) = token.split_once('.').unwrap(); | |
| 186 | + let extended = format!("{}.{rest}", exp.parse::<i64>().unwrap() + 1000); | |
| 187 | + assert!(!verify(&KEY, &extended, "repo-1", LfsOp::Download, NOW)); | |
| 188 | + // Flipping a MAC character must fail. | |
| 189 | + let mut bytes = token.into_bytes(); | |
| 190 | + let last = bytes.len() - 1; | |
| 191 | + bytes[last] = if bytes[last] == b'A' { b'B' } else { b'A' }; | |
| 192 | + let flipped = String::from_utf8(bytes).unwrap(); | |
| 193 | + assert!(!verify(&KEY, &flipped, "repo-1", LfsOp::Download, NOW)); | |
| 194 | + } | |
| 195 | + | |
| 196 | + #[test] | |
| 197 | + fn malformed_is_rejected() { | |
| 198 | + assert!(!verify(&KEY, "", "repo-1", LfsOp::Any, NOW)); | |
| 199 | + assert!(!verify(&KEY, "garbage", "repo-1", LfsOp::Any, NOW)); | |
| 200 | + assert!(!verify(&KEY, "1.any", "repo-1", LfsOp::Any, NOW)); | |
| 201 | + assert!(!verify( | |
| 202 | + &KEY, | |
| 203 | + "+1700000060.any.AAAA", | |
| 204 | + "repo-1", | |
| 205 | + LfsOp::Any, | |
| 206 | + NOW | |
| 207 | + )); | |
| 208 | + assert!(!verify( | |
| 209 | + &KEY, | |
| 210 | + "1700000060.bogus.AAAA", | |
| 211 | + "repo-1", | |
| 212 | + LfsOp::Any, | |
| 213 | + NOW | |
| 214 | + )); | |
| 215 | + } | |
| 216 | +} |
crates/forge-core/src/lib.rs| @@ -0,0 +1,46 @@ | ||
| 1 | +//! Domain types, storage, configuration and security primitives for forge. | |
| 2 | + | |
| 3 | +pub mod advisories; | |
| 4 | +pub mod audit; | |
| 5 | +pub mod authz; | |
| 6 | +pub mod avatar; | |
| 7 | +pub mod ci; | |
| 8 | +pub mod config; | |
| 9 | +pub mod discussions; | |
| 10 | +pub mod error; | |
| 11 | +pub mod issue_refs; | |
| 12 | +pub mod issues; | |
| 13 | +pub mod lfs_token; | |
| 14 | +pub mod mail; | |
| 15 | +pub mod mirrors; | |
| 16 | +pub mod model; | |
| 17 | +pub mod names; | |
| 18 | +pub mod notifications; | |
| 19 | +pub mod password; | |
| 20 | +pub mod protection; | |
| 21 | +pub mod pulls; | |
| 22 | +pub mod raw_token; | |
| 23 | +pub mod reactions; | |
| 24 | +pub mod releases; | |
| 25 | +pub mod reviews; | |
| 26 | +pub mod secret; | |
| 27 | +pub mod session; | |
| 28 | +pub mod store; | |
| 29 | +pub mod time; | |
| 30 | +pub mod token; | |
| 31 | +pub mod totp; | |
| 32 | +pub mod tz; | |
| 33 | +pub mod webhooks; | |
| 34 | + | |
| 35 | +pub use audit::*; | |
| 36 | +pub use authz::{Action, Denied, authorize}; | |
| 37 | +pub use config::{Config, Registration}; | |
| 38 | +pub use error::{Error, Result}; | |
| 39 | +pub use issues::*; | |
| 40 | +pub use model::*; | |
| 41 | +pub use notifications::*; | |
| 42 | +pub use pulls::*; | |
| 43 | +pub use reactions::{Reaction, ReactionMap, ReactionSummary, ReactionTarget}; | |
| 44 | +pub use reviews::*; | |
| 45 | +pub use store::Store; | |
| 46 | +pub use store::edit_history_read::EditVersion; |
crates/forge-core/src/mail.rs| @@ -0,0 +1,366 @@ | ||
| 1 | +//! Outgoing mail: the SMTP settings, the outbox states, the retry schedule and the send error. | |
| 2 | +//! The store functions live in store/mail.rs; the transports live in forge-web (mail/). | |
| 3 | + | |
| 4 | +use std::fmt; | |
| 5 | +use std::time::Duration; | |
| 6 | + | |
| 7 | +/// Tries per message: one first try and five retries. Once the last try fails, the message gives up. | |
| 8 | +pub const MAX_ATTEMPTS: u32 = 6; | |
| 9 | + | |
| 10 | +/// The wait after the first, second, ... failed try. Five waits serve five retries. | |
| 11 | +const BACKOFF: [Duration; 5] = [ | |
| 12 | + Duration::from_secs(60), | |
| 13 | + Duration::from_secs(5 * 60), | |
| 14 | + Duration::from_secs(30 * 60), | |
| 15 | + Duration::from_secs(2 * 60 * 60), | |
| 16 | + Duration::from_secs(12 * 60 * 60), | |
| 17 | +]; | |
| 18 | + | |
| 19 | +/// The wait before the next try, given that `attempts` tries have failed. `None` means give up. | |
| 20 | +pub fn retry_delay(attempts: u32) -> Option<Duration> { | |
| 21 | + if attempts == 0 || attempts >= MAX_ATTEMPTS { | |
| 22 | + return None; | |
| 23 | + } | |
| 24 | + BACKOFF.get((attempts - 1) as usize).copied() | |
| 25 | +} | |
| 26 | + | |
| 27 | +/// How the SMTP connection is secured. | |
| 28 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 29 | +pub enum Security { | |
| 30 | + /// Plain connection upgraded with STARTTLS (port 587 usually). | |
| 31 | + StartTls, | |
| 32 | + /// TLS from the first byte (port 465 usually). | |
| 33 | + Tls, | |
| 34 | + /// No encryption. Only for a relay on a trusted network. | |
| 35 | + Plain, | |
| 36 | +} | |
| 37 | + | |
| 38 | +impl Security { | |
| 39 | + pub fn as_str(self) -> &'static str { | |
| 40 | + match self { | |
| 41 | + Security::StartTls => "starttls", | |
| 42 | + Security::Tls => "tls", | |
| 43 | + Security::Plain => "none", | |
| 44 | + } | |
| 45 | + } | |
| 46 | + | |
| 47 | + pub fn parse(s: &str) -> Option<Security> { | |
| 48 | + match s { | |
| 49 | + "starttls" => Some(Security::StartTls), | |
| 50 | + "tls" => Some(Security::Tls), | |
| 51 | + "none" => Some(Security::Plain), | |
| 52 | + _ => None, | |
| 53 | + } | |
| 54 | + } | |
| 55 | +} | |
| 56 | + | |
| 57 | +/// The stored SMTP settings. The password is never part of this value; `has_password` says whether | |
| 58 | +/// one is stored. | |
| 59 | +#[derive(Debug, Clone, PartialEq, Eq)] | |
| 60 | +pub struct MailSettings { | |
| 61 | + pub host: String, | |
| 62 | + pub port: u16, | |
| 63 | + pub security: Security, | |
| 64 | + pub username: String, | |
| 65 | + pub from_addr: String, | |
| 66 | + pub from_name: String, | |
| 67 | + pub enabled: bool, | |
| 68 | + pub has_password: bool, | |
| 69 | + pub updated_at: Option<String>, | |
| 70 | +} | |
| 71 | + | |
| 72 | +/// A change to the SMTP settings. `password: None` keeps the stored password; `Some` replaces it. | |
| 73 | +pub struct MailSettingsUpdate<'a> { | |
| 74 | + pub host: &'a str, | |
| 75 | + pub port: u16, | |
| 76 | + pub security: Security, | |
| 77 | + pub username: &'a str, | |
| 78 | + pub from_addr: &'a str, | |
| 79 | + pub from_name: &'a str, | |
| 80 | + pub enabled: bool, | |
| 81 | + pub password: Option<&'a str>, | |
| 82 | +} | |
| 83 | + | |
| 84 | +impl fmt::Debug for MailSettingsUpdate<'_> { | |
| 85 | + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 86 | + f.debug_struct("MailSettingsUpdate") | |
| 87 | + .field("host", &self.host) | |
| 88 | + .field("port", &self.port) | |
| 89 | + .field("security", &self.security) | |
| 90 | + .field("username", &self.username) | |
| 91 | + .field("from_addr", &self.from_addr) | |
| 92 | + .field("from_name", &self.from_name) | |
| 93 | + .field("enabled", &self.enabled) | |
| 94 | + .field("password", &self.password.map(|_| "<redacted>")) | |
| 95 | + .finish() | |
| 96 | + } | |
| 97 | +} | |
| 98 | + | |
| 99 | +/// The decrypted SMTP password. Its Debug output is redacted. | |
| 100 | +#[derive(Clone, PartialEq, Eq)] | |
| 101 | +pub struct MailPassword(String); | |
| 102 | + | |
| 103 | +impl MailPassword { | |
| 104 | + pub fn new(value: String) -> MailPassword { | |
| 105 | + MailPassword(value) | |
| 106 | + } | |
| 107 | + | |
| 108 | + pub fn expose(&self) -> &str { | |
| 109 | + &self.0 | |
| 110 | + } | |
| 111 | +} | |
| 112 | + | |
| 113 | +impl fmt::Debug for MailPassword { | |
| 114 | + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 115 | + f.write_str("MailPassword(<redacted>)") | |
| 116 | + } | |
| 117 | +} | |
| 118 | + | |
| 119 | +/// One message to send. Its Debug output omits the body, which can carry one-time links. | |
| 120 | +#[derive(Clone, PartialEq, Eq)] | |
| 121 | +pub struct OutgoingMail { | |
| 122 | + pub to: String, | |
| 123 | + pub subject: String, | |
| 124 | + pub body: String, | |
| 125 | + /// `Message-ID` header, in `<...>` form. `None` for mail that is not part of a thread. | |
| 126 | + pub message_id: Option<String>, | |
| 127 | + /// `In-Reply-To` and `References` headers, in `<...>` form. | |
| 128 | + pub in_reply_to: Option<String>, | |
| 129 | +} | |
| 130 | + | |
| 131 | +impl fmt::Debug for OutgoingMail { | |
| 132 | + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 133 | + f.debug_struct("OutgoingMail") | |
| 134 | + .field("to", &self.to) | |
| 135 | + .field("subject", &self.subject) | |
| 136 | + .field("body", &"<redacted>") | |
| 137 | + .finish() | |
| 138 | + } | |
| 139 | +} | |
| 140 | + | |
| 141 | +/// One outbox row. The body is kept for the worker; the admin list never shows it. | |
| 142 | +#[derive(Clone, PartialEq, Eq)] | |
| 143 | +pub struct OutboxEntry { | |
| 144 | + pub id: String, | |
| 145 | + pub idem_key: String, | |
| 146 | + pub to_addr: String, | |
| 147 | + pub subject: String, | |
| 148 | + pub body: String, | |
| 149 | + pub message_id: Option<String>, | |
| 150 | + pub in_reply_to: Option<String>, | |
| 151 | + pub created_at: String, | |
| 152 | + /// Tries made so far. | |
| 153 | + pub attempts: u32, | |
| 154 | + pub next_attempt_at: Option<String>, | |
| 155 | + pub sent_at: Option<String>, | |
| 156 | + pub last_error: Option<String>, | |
| 157 | +} | |
| 158 | + | |
| 159 | +impl fmt::Debug for OutboxEntry { | |
| 160 | + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 161 | + f.debug_struct("OutboxEntry") | |
| 162 | + .field("id", &self.id) | |
| 163 | + .field("to_addr", &self.to_addr) | |
| 164 | + .field("subject", &self.subject) | |
| 165 | + .field("body", &"<redacted>") | |
| 166 | + .field("attempts", &self.attempts) | |
| 167 | + .field("next_attempt_at", &self.next_attempt_at) | |
| 168 | + .field("sent_at", &self.sent_at) | |
| 169 | + .field("last_error", &self.last_error) | |
| 170 | + .finish() | |
| 171 | + } | |
| 172 | +} | |
| 173 | + | |
| 174 | +/// Where a message stands. | |
| 175 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 176 | +pub enum OutboxStatus { | |
| 177 | + Sent, | |
| 178 | + Queued, | |
| 179 | + GaveUp, | |
| 180 | +} | |
| 181 | + | |
| 182 | +impl OutboxStatus { | |
| 183 | + pub fn label(self) -> &'static str { | |
| 184 | + match self { | |
| 185 | + OutboxStatus::Sent => "sent", | |
| 186 | + OutboxStatus::Queued => "queued", | |
| 187 | + OutboxStatus::GaveUp => "gave up", | |
| 188 | + } | |
| 189 | + } | |
| 190 | +} | |
| 191 | + | |
| 192 | +impl OutboxEntry { | |
| 193 | + pub fn status(&self) -> OutboxStatus { | |
| 194 | + if self.sent_at.is_some() { | |
| 195 | + OutboxStatus::Sent | |
| 196 | + } else if self.next_attempt_at.is_some() { | |
| 197 | + OutboxStatus::Queued | |
| 198 | + } else { | |
| 199 | + OutboxStatus::GaveUp | |
| 200 | + } | |
| 201 | + } | |
| 202 | +} | |
| 203 | + | |
| 204 | +/// Why a send failed. A transient failure is retried on the schedule; a permanent one gives up now. | |
| 205 | +#[derive(Debug, Clone, PartialEq, Eq)] | |
| 206 | +pub enum MailError { | |
| 207 | + Transient(String), | |
| 208 | + Permanent(String), | |
| 209 | +} | |
| 210 | + | |
| 211 | +impl fmt::Display for MailError { | |
| 212 | + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | |
| 213 | + match self { | |
| 214 | + MailError::Transient(m) | MailError::Permanent(m) => f.write_str(m), | |
| 215 | + } | |
| 216 | + } | |
| 217 | +} | |
| 218 | + | |
| 219 | +/// Checks the SMTP settings before they are stored. `enabled` needs a host and a sender. | |
| 220 | +pub fn validate_settings(update: &MailSettingsUpdate<'_>) -> crate::Result<()> { | |
| 221 | + use crate::Error; | |
| 222 | + let bad = |m: &str| Err(Error::Invalid(m.to_owned())); | |
| 223 | + if update.port == 0 { | |
| 224 | + return bad("the SMTP port must be between 1 and 65535"); | |
| 225 | + } | |
| 226 | + if update.host.chars().count() > 253 | |
| 227 | + || update | |
| 228 | + .host | |
| 229 | + .chars() | |
| 230 | + .any(|c| c.is_whitespace() || c.is_control() || c == '/') | |
| 231 | + { | |
| 232 | + return bad("the SMTP host is not a host name"); | |
| 233 | + } | |
| 234 | + if update.username.chars().count() > 256 || update.username.chars().any(char::is_control) { | |
| 235 | + return bad("the SMTP username is too long or has control characters"); | |
| 236 | + } | |
| 237 | + if update.from_name.chars().count() > 100 || update.from_name.chars().any(char::is_control) { | |
| 238 | + return bad("the sender name must be at most 100 characters with no control characters"); | |
| 239 | + } | |
| 240 | + if !update.from_addr.is_empty() { | |
| 241 | + crate::store::validate_email(update.from_addr)?; | |
| 242 | + } | |
| 243 | + if update.enabled { | |
| 244 | + if update.host.is_empty() { | |
| 245 | + return bad("set the SMTP host before turning mail on"); | |
| 246 | + } | |
| 247 | + if update.from_addr.is_empty() { | |
| 248 | + return bad("set the sender address before turning mail on"); | |
| 249 | + } | |
| 250 | + } | |
| 251 | + Ok(()) | |
| 252 | +} | |
| 253 | + | |
| 254 | +#[cfg(test)] | |
| 255 | +mod tests { | |
| 256 | + use super::*; | |
| 257 | + | |
| 258 | + #[test] | |
| 259 | + fn retry_schedule_is_five_waits_then_give_up() { | |
| 260 | + let mins = |n: u32| retry_delay(n).map(|d| d.as_secs() / 60); | |
| 261 | + assert_eq!(retry_delay(0), None, "no try has failed yet"); | |
| 262 | + assert_eq!(mins(1), Some(1)); | |
| 263 | + assert_eq!(mins(2), Some(5)); | |
| 264 | + assert_eq!(mins(3), Some(30)); | |
| 265 | + assert_eq!(mins(4), Some(120)); | |
| 266 | + assert_eq!(mins(5), Some(720)); | |
| 267 | + assert_eq!(mins(6), None, "the sixth failed try gives up"); | |
| 268 | + assert_eq!(MAX_ATTEMPTS, 6); | |
| 269 | + } | |
| 270 | + | |
| 271 | + #[test] | |
| 272 | + fn security_round_trips_and_rejects_unknown_names() { | |
| 273 | + for s in [Security::StartTls, Security::Tls, Security::Plain] { | |
| 274 | + assert_eq!(Security::parse(s.as_str()), Some(s)); | |
| 275 | + } | |
| 276 | + assert_eq!(Security::parse("ssl"), None); | |
| 277 | + } | |
| 278 | + | |
| 279 | + #[test] | |
| 280 | + fn debug_output_never_shows_the_password_or_the_body() { | |
| 281 | + let pw = MailPassword::new("hunter2-smtp".into()); | |
| 282 | + assert!(!format!("{pw:?}").contains("hunter2")); | |
| 283 | + let update = MailSettingsUpdate { | |
| 284 | + host: "smtp.example", | |
| 285 | + port: 587, | |
| 286 | + security: Security::StartTls, | |
| 287 | + username: "u", | |
| 288 | + from_addr: "forge@example.com", | |
| 289 | + from_name: "", | |
| 290 | + enabled: true, | |
| 291 | + password: Some("hunter2-smtp"), | |
| 292 | + }; | |
| 293 | + let shown = format!("{update:?}"); | |
| 294 | + assert!(!shown.contains("hunter2")); | |
| 295 | + assert!(shown.contains("<redacted>")); | |
| 296 | + let mail = OutgoingMail { | |
| 297 | + to: "a@example.com".into(), | |
| 298 | + subject: "Reset".into(), | |
| 299 | + body: "https://forge.test/reset/SECRET-TOKEN".into(), | |
| 300 | + message_id: None, | |
| 301 | + in_reply_to: None, | |
| 302 | + }; | |
| 303 | + assert!(!format!("{mail:?}").contains("SECRET-TOKEN")); | |
| 304 | + } | |
| 305 | + | |
| 306 | + #[test] | |
| 307 | + fn validation_requires_host_and_sender_before_enabling() { | |
| 308 | + let base = MailSettingsUpdate { | |
| 309 | + host: "smtp.example", | |
| 310 | + port: 587, | |
| 311 | + security: Security::StartTls, | |
| 312 | + username: "", | |
| 313 | + from_addr: "forge@example.com", | |
| 314 | + from_name: "forge", | |
| 315 | + enabled: true, | |
| 316 | + password: None, | |
| 317 | + }; | |
| 318 | + assert!(validate_settings(&base).is_ok()); | |
| 319 | + let no_host = MailSettingsUpdate { host: "", ..base }; | |
| 320 | + assert!(validate_settings(&no_host).is_err()); | |
| 321 | + let off = MailSettingsUpdate { | |
| 322 | + host: "", | |
| 323 | + enabled: false, | |
| 324 | + ..base | |
| 325 | + }; | |
| 326 | + assert!( | |
| 327 | + validate_settings(&off).is_ok(), | |
| 328 | + "settings may be saved while off" | |
| 329 | + ); | |
| 330 | + let no_port = MailSettingsUpdate { port: 0, ..base }; | |
| 331 | + assert!(validate_settings(&no_port).is_err()); | |
| 332 | + let bad_host = MailSettingsUpdate { | |
| 333 | + host: "smtp.example/evil", | |
| 334 | + ..base | |
| 335 | + }; | |
| 336 | + assert!(validate_settings(&bad_host).is_err()); | |
| 337 | + let bad_from = MailSettingsUpdate { | |
| 338 | + from_addr: "not-an-address", | |
| 339 | + ..base | |
| 340 | + }; | |
| 341 | + assert!(validate_settings(&bad_from).is_err()); | |
| 342 | + } | |
| 343 | + | |
| 344 | + #[test] | |
| 345 | + fn status_follows_sent_at_and_next_attempt() { | |
| 346 | + let mut entry = OutboxEntry { | |
| 347 | + id: "1".into(), | |
| 348 | + idem_key: "k".into(), | |
| 349 | + to_addr: "a@example.com".into(), | |
| 350 | + subject: "s".into(), | |
| 351 | + body: "b".into(), | |
| 352 | + message_id: None, | |
| 353 | + in_reply_to: None, | |
| 354 | + created_at: "t".into(), | |
| 355 | + attempts: 0, | |
| 356 | + next_attempt_at: Some("t".into()), | |
| 357 | + sent_at: None, | |
| 358 | + last_error: None, | |
| 359 | + }; | |
| 360 | + assert_eq!(entry.status(), OutboxStatus::Queued); | |
| 361 | + entry.next_attempt_at = None; | |
| 362 | + assert_eq!(entry.status(), OutboxStatus::GaveUp); | |
| 363 | + entry.sent_at = Some("t".into()); | |
| 364 | + assert_eq!(entry.status(), OutboxStatus::Sent); | |
| 365 | + } | |
| 366 | +} |
crates/forge-core/src/mirrors.rs| @@ -0,0 +1,86 @@ | ||
| 1 | +//! Push and pull mirrors: the direction enum and the mirror shape. The stored shape is in | |
| 2 | +//! migrations/0017_mirrors.sql; the store functions live in store/mirrors.rs. | |
| 3 | +//! | |
| 4 | +//! A mirror never carries its secret. `has_secret` says whether one is stored; the plaintext is | |
| 5 | +//! read back only through `Store::mirror_secret`, for the sync worker. | |
| 6 | + | |
| 7 | +use crate::model::Id; | |
| 8 | + | |
| 9 | +/// Which way refs flow. Stored as the `as_str` name. | |
| 10 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 11 | +pub enum MirrorDirection { | |
| 12 | + /// The repo's refs come from the remote URL. | |
| 13 | + Pull, | |
| 14 | + /// Our refs are pushed to the remote URL. | |
| 15 | + Push, | |
| 16 | +} | |
| 17 | + | |
| 18 | +impl MirrorDirection { | |
| 19 | + pub fn as_str(self) -> &'static str { | |
| 20 | + match self { | |
| 21 | + MirrorDirection::Pull => "pull", | |
| 22 | + MirrorDirection::Push => "push", | |
| 23 | + } | |
| 24 | + } | |
| 25 | +} | |
| 26 | + | |
| 27 | +impl std::str::FromStr for MirrorDirection { | |
| 28 | + type Err = crate::error::Error; | |
| 29 | + | |
| 30 | + fn from_str(s: &str) -> Result<Self, Self::Err> { | |
| 31 | + match s { | |
| 32 | + "pull" => Ok(MirrorDirection::Pull), | |
| 33 | + "push" => Ok(MirrorDirection::Push), | |
| 34 | + other => Err(crate::error::Error::Database(format!( | |
| 35 | + "unknown mirror direction {other:?}" | |
| 36 | + ))), | |
| 37 | + } | |
| 38 | + } | |
| 39 | +} | |
| 40 | + | |
| 41 | +/// A repo mirror. Has no secret field; see `Store::mirror_secret`. | |
| 42 | +#[derive(Debug, Clone, PartialEq, Eq)] | |
| 43 | +pub struct Mirror { | |
| 44 | + pub id: Id, | |
| 45 | + pub repo_id: Id, | |
| 46 | + pub direction: MirrorDirection, | |
| 47 | + pub url: String, | |
| 48 | + pub username: String, | |
| 49 | + pub has_secret: bool, | |
| 50 | + pub interval_minutes: u32, | |
| 51 | + pub enabled: bool, | |
| 52 | + pub next_sync_at: String, | |
| 53 | + pub last_sync_at: Option<String>, | |
| 54 | + pub last_ok: Option<bool>, | |
| 55 | + pub last_error: Option<String>, | |
| 56 | + pub created_at: String, | |
| 57 | +} | |
| 58 | + | |
| 59 | +/// Input for `Store::create_mirror`. `secret` is plaintext; the store seals it. | |
| 60 | +#[derive(Debug, Clone, Copy)] | |
| 61 | +pub struct NewMirror<'a> { | |
| 62 | + pub repo_id: &'a str, | |
| 63 | + pub direction: MirrorDirection, | |
| 64 | + pub url: &'a str, | |
| 65 | + pub username: &'a str, | |
| 66 | + pub secret: Option<&'a [u8]>, | |
| 67 | + pub interval_minutes: u32, | |
| 68 | +} | |
| 69 | + | |
| 70 | +/// What `Store::update_mirror` does with the stored secret. | |
| 71 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 72 | +pub enum SecretUpdate<'a> { | |
| 73 | + /// Leave the stored secret as it is. | |
| 74 | + Keep, | |
| 75 | + /// Remove the stored secret. | |
| 76 | + Clear, | |
| 77 | + /// Replace the stored secret with this plaintext, which the store seals. | |
| 78 | + Set(&'a [u8]), | |
| 79 | +} | |
| 80 | + | |
| 81 | +/// Most push mirrors one repo may have. | |
| 82 | +pub const MAX_PUSH_MIRRORS: u32 = 10; | |
| 83 | +/// Shortest allowed sync interval, in minutes. `0` means manual only. | |
| 84 | +pub const MIN_INTERVAL_MINUTES: u32 = 10; | |
| 85 | +/// Default sync interval, in minutes. | |
| 86 | +pub const DEFAULT_INTERVAL_MINUTES: u32 = 480; |
crates/forge-core/src/model.rs| @@ -0,0 +1,255 @@ | ||
| 1 | +//! Domain types. Stored shapes are described in docs/m1-interfaces.md. | |
| 2 | + | |
| 3 | +pub type Id = String; | |
| 4 | + | |
| 5 | +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] | |
| 6 | +#[serde(rename_all = "lowercase")] | |
| 7 | +pub enum Visibility { | |
| 8 | + Public, | |
| 9 | + Private, | |
| 10 | +} | |
| 11 | + | |
| 12 | +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] | |
| 13 | +#[serde(rename_all = "lowercase")] | |
| 14 | +pub enum Role { | |
| 15 | + Read, | |
| 16 | + Triage, | |
| 17 | + Write, | |
| 18 | + Maintain, | |
| 19 | + Admin, | |
| 20 | +} | |
| 21 | + | |
| 22 | +/// What a `users` row stands for. An org is a row with `Org`; it can never authenticate. | |
| 23 | +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] | |
| 24 | +#[serde(rename_all = "lowercase")] | |
| 25 | +pub enum AccountKind { | |
| 26 | + User, | |
| 27 | + Org, | |
| 28 | +} | |
| 29 | + | |
| 30 | +#[derive(Clone, PartialEq)] | |
| 31 | +pub struct User { | |
| 32 | + pub id: Id, | |
| 33 | + pub username: String, | |
| 34 | + pub email: Option<String>, | |
| 35 | + pub password_hash: String, | |
| 36 | + pub is_admin: bool, | |
| 37 | + pub created_at: String, | |
| 38 | + /// Raw markdown, at most 2000 chars. | |
| 39 | + pub about: String, | |
| 40 | + /// IANA name, e.g. "Europe/Istanbul"; "UTC" by default. | |
| 41 | + pub timezone: String, | |
| 42 | + /// Hash of the current avatar, or `None` when the user has none. Filled by the user queries. | |
| 43 | + pub avatar_hash: Option<String>, | |
| 44 | + /// Colour theme: "auto", "light" or "dark". Follows the browser when "auto". | |
| 45 | + pub theme: String, | |
| 46 | + /// Language code, e.g. "en"; "auto" uses the instance default. | |
| 47 | + pub language: String, | |
| 48 | + /// `User` for a person, `Org` for an organization. | |
| 49 | + pub kind: AccountKind, | |
| 50 | + /// Whether notifications are also sent by email (needs a verified email and instance mail). | |
| 51 | + pub email_notifications: bool, | |
| 52 | +} | |
| 53 | + | |
| 54 | +impl User { | |
| 55 | + /// True for an organization row. Orgs never authenticate and never act as a person. | |
| 56 | + pub fn is_org(&self) -> bool { | |
| 57 | + self.kind == AccountKind::Org | |
| 58 | + } | |
| 59 | +} | |
| 60 | + | |
| 61 | +#[derive(Debug, Clone, PartialEq)] | |
| 62 | +pub struct Repo { | |
| 63 | + pub id: Id, | |
| 64 | + pub owner_id: Id, | |
| 65 | + pub owner_name: String, | |
| 66 | + pub name: String, | |
| 67 | + pub description: String, | |
| 68 | + pub visibility: Visibility, | |
| 69 | + pub default_branch: String, | |
| 70 | + pub archived: bool, | |
| 71 | + pub created_at: String, | |
| 72 | + /// The repo this one was forked from. `None` for originals and for forks whose parent was | |
| 73 | + /// deleted. | |
| 74 | + pub fork_of: Option<Id>, | |
| 75 | +} | |
| 76 | + | |
| 77 | +#[derive(Debug, Clone, PartialEq)] | |
| 78 | +pub struct SshKey { | |
| 79 | + pub id: Id, | |
| 80 | + pub user_id: Id, | |
| 81 | + pub title: String, | |
| 82 | + pub algorithm: String, | |
| 83 | + /// Raw SSH wire-format public key blob (the bytes base64-encoded in authorized_keys). | |
| 84 | + pub key_blob: Vec<u8>, | |
| 85 | + pub fingerprint: String, | |
| 86 | + pub created_at: String, | |
| 87 | + pub last_used_at: Option<String>, | |
| 88 | +} | |
| 89 | + | |
| 90 | +#[derive(Debug, Clone, PartialEq)] | |
| 91 | +pub struct AccessToken { | |
| 92 | + pub id: Id, | |
| 93 | + pub user_id: Id, | |
| 94 | + pub name: String, | |
| 95 | + pub scopes: Vec<String>, | |
| 96 | + pub expires_at: Option<String>, | |
| 97 | + pub created_at: String, | |
| 98 | + pub last_used_at: Option<String>, | |
| 99 | +} | |
| 100 | + | |
| 101 | +#[derive(Clone, PartialEq)] | |
| 102 | +pub struct Session { | |
| 103 | + pub id: Id, | |
| 104 | + pub user_id: Id, | |
| 105 | + pub csrf_token: String, | |
| 106 | + pub created_at: String, | |
| 107 | + pub expires_at: String, | |
| 108 | + pub last_seen: String, | |
| 109 | + pub user_agent: Option<String>, | |
| 110 | +} | |
| 111 | + | |
| 112 | +const REDACTED: &str = "<redacted>"; | |
| 113 | + | |
| 114 | +impl std::fmt::Debug for User { | |
| 115 | + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 116 | + f.debug_struct("User") | |
| 117 | + .field("id", &self.id) | |
| 118 | + .field("username", &self.username) | |
| 119 | + .field("email", &self.email) | |
| 120 | + .field("password_hash", &REDACTED) | |
| 121 | + .field("is_admin", &self.is_admin) | |
| 122 | + .field("created_at", &self.created_at) | |
| 123 | + .field("about", &self.about) | |
| 124 | + .field("timezone", &self.timezone) | |
| 125 | + .field("avatar_hash", &self.avatar_hash) | |
| 126 | + .field("theme", &self.theme) | |
| 127 | + .field("language", &self.language) | |
| 128 | + .field("kind", &self.kind) | |
| 129 | + .finish() | |
| 130 | + } | |
| 131 | +} | |
| 132 | + | |
| 133 | +impl std::fmt::Debug for Session { | |
| 134 | + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 135 | + f.debug_struct("Session") | |
| 136 | + .field("id", &self.id) | |
| 137 | + .field("user_id", &self.user_id) | |
| 138 | + .field("csrf_token", &REDACTED) | |
| 139 | + .field("created_at", &self.created_at) | |
| 140 | + .field("expires_at", &self.expires_at) | |
| 141 | + .field("last_seen", &self.last_seen) | |
| 142 | + .field("user_agent", &self.user_agent) | |
| 143 | + .finish() | |
| 144 | + } | |
| 145 | +} | |
| 146 | + | |
| 147 | +impl Visibility { | |
| 148 | + pub fn as_str(&self) -> &'static str { | |
| 149 | + match self { | |
| 150 | + Visibility::Public => "public", | |
| 151 | + Visibility::Private => "private", | |
| 152 | + } | |
| 153 | + } | |
| 154 | +} | |
| 155 | + | |
| 156 | +impl std::str::FromStr for Visibility { | |
| 157 | + type Err = crate::error::Error; | |
| 158 | + | |
| 159 | + fn from_str(s: &str) -> Result<Self, Self::Err> { | |
| 160 | + match s { | |
| 161 | + "public" => Ok(Visibility::Public), | |
| 162 | + "private" => Ok(Visibility::Private), | |
| 163 | + other => Err(crate::error::Error::Invalid(format!( | |
| 164 | + "visibility {other:?}" | |
| 165 | + ))), | |
| 166 | + } | |
| 167 | + } | |
| 168 | +} | |
| 169 | + | |
| 170 | +impl Role { | |
| 171 | + pub fn as_str(&self) -> &'static str { | |
| 172 | + match self { | |
| 173 | + Role::Read => "read", | |
| 174 | + Role::Triage => "triage", | |
| 175 | + Role::Write => "write", | |
| 176 | + Role::Maintain => "maintain", | |
| 177 | + Role::Admin => "admin", | |
| 178 | + } | |
| 179 | + } | |
| 180 | +} | |
| 181 | + | |
| 182 | +impl std::str::FromStr for Role { | |
| 183 | + type Err = crate::error::Error; | |
| 184 | + | |
| 185 | + fn from_str(s: &str) -> Result<Self, Self::Err> { | |
| 186 | + match s { | |
| 187 | + "read" => Ok(Role::Read), | |
| 188 | + "triage" => Ok(Role::Triage), | |
| 189 | + "write" => Ok(Role::Write), | |
| 190 | + "maintain" => Ok(Role::Maintain), | |
| 191 | + "admin" => Ok(Role::Admin), | |
| 192 | + other => Err(crate::error::Error::Invalid(format!("role {other:?}"))), | |
| 193 | + } | |
| 194 | + } | |
| 195 | +} | |
| 196 | + | |
| 197 | +#[cfg(test)] | |
| 198 | +mod tests { | |
| 199 | + use super::*; | |
| 200 | + | |
| 201 | + #[test] | |
| 202 | + fn debug_output_redacts_secrets() { | |
| 203 | + let user = User { | |
| 204 | + id: "u1".into(), | |
| 205 | + username: "alice".into(), | |
| 206 | + email: None, | |
| 207 | + password_hash: "$argon2id$SECRETHASH".into(), | |
| 208 | + is_admin: true, | |
| 209 | + created_at: "now".into(), | |
| 210 | + about: String::new(), | |
| 211 | + timezone: "UTC".into(), | |
| 212 | + avatar_hash: None, | |
| 213 | + theme: "auto".into(), | |
| 214 | + language: "auto".into(), | |
| 215 | + kind: AccountKind::User, | |
| 216 | + email_notifications: true, | |
| 217 | + }; | |
| 218 | + let session = Session { | |
| 219 | + id: "s1".into(), | |
| 220 | + user_id: "u1".into(), | |
| 221 | + csrf_token: "CSRFSECRET".into(), | |
| 222 | + created_at: "now".into(), | |
| 223 | + expires_at: "later".into(), | |
| 224 | + last_seen: "now".into(), | |
| 225 | + user_agent: None, | |
| 226 | + }; | |
| 227 | + let dbg = format!("{user:?} {session:?}"); | |
| 228 | + assert!(dbg.contains("\"<redacted>\"")); | |
| 229 | + assert!(!dbg.contains("SECRETHASH")); | |
| 230 | + assert!(!dbg.contains("CSRFSECRET")); | |
| 231 | + assert!(dbg.contains("alice")); | |
| 232 | + } | |
| 233 | + | |
| 234 | + #[test] | |
| 235 | + fn visibility_round_trips() { | |
| 236 | + for v in [Visibility::Public, Visibility::Private] { | |
| 237 | + assert_eq!(v.as_str().parse::<Visibility>().ok(), Some(v)); | |
| 238 | + } | |
| 239 | + assert!("Public".parse::<Visibility>().is_err()); | |
| 240 | + } | |
| 241 | + | |
| 242 | + #[test] | |
| 243 | + fn role_round_trips() { | |
| 244 | + for r in [ | |
| 245 | + Role::Read, | |
| 246 | + Role::Triage, | |
| 247 | + Role::Write, | |
| 248 | + Role::Maintain, | |
| 249 | + Role::Admin, | |
| 250 | + ] { | |
| 251 | + assert_eq!(r.as_str().parse::<Role>().ok(), Some(r)); | |
| 252 | + } | |
| 253 | + assert!("owner".parse::<Role>().is_err()); | |
| 254 | + } | |
| 255 | +} |
crates/forge-core/src/names.rs| @@ -0,0 +1,252 @@ | ||
| 1 | +//! Owner and repo name rules. | |
| 2 | + | |
| 3 | +use unicode_normalization::UnicodeNormalization; | |
| 4 | + | |
| 5 | +use crate::error::{Error, Result}; | |
| 6 | + | |
| 7 | +/// Names that may not be used as owners or repos (they collide with routes). | |
| 8 | +pub const RESERVED: &[&str] = &[ | |
| 9 | + "api", | |
| 10 | + "admin", | |
| 11 | + "auth", | |
| 12 | + "assets", | |
| 13 | + "static", | |
| 14 | + "healthz", | |
| 15 | + "login", | |
| 16 | + "logout", | |
| 17 | + "register", | |
| 18 | + "setup", | |
| 19 | + "settings", | |
| 20 | + "new", | |
| 21 | + "explore", | |
| 22 | + "user", | |
| 23 | + "users", | |
| 24 | + "repo", | |
| 25 | + "repos", | |
| 26 | + "org", | |
| 27 | + "orgs", | |
| 28 | + "raw", | |
| 29 | + "git", | |
| 30 | + "internal", | |
| 31 | + "help", | |
| 32 | + "about", | |
| 33 | + "avatars", | |
| 34 | + "search", | |
| 35 | + "notifications", | |
| 36 | + "dashboard", | |
| 37 | + "issues", | |
| 38 | + "pulls", | |
| 39 | + "snippets", | |
| 40 | + // The reserved account that authored content is reassigned to on deletion. | |
| 41 | + "ghost", | |
| 42 | + "favicon.ico", | |
| 43 | + "robots.txt", | |
| 44 | + "security.txt", | |
| 45 | + ".well-known", | |
| 46 | + "-", | |
| 47 | + ".", | |
| 48 | + "..", | |
| 49 | +]; | |
| 50 | + | |
| 51 | +/// NFKC normalization plus case fold. | |
| 52 | +pub fn normalize_name(input: &str) -> String { | |
| 53 | + input.nfkc().collect::<String>().to_lowercase() | |
| 54 | +} | |
| 55 | + | |
| 56 | +/// Returns the folded name, or `Error::InvalidName`. | |
| 57 | +/// Allowed characters `[a-z0-9._-]`; must not start with `-` or `.`; must not end in `.git` or `.atom`. | |
| 58 | +/// Repo names must also not end in `.wiki`, so that `<name>.wiki.git` names the wiki unambiguously. | |
| 59 | +pub fn validate_name(input: &str) -> Result<String> { | |
| 60 | + let name = normalize_name(input); | |
| 61 | + if !name | |
| 62 | + .chars() | |
| 63 | + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '-')) | |
| 64 | + { | |
| 65 | + return Err(invalid( | |
| 66 | + input, | |
| 67 | + "only a-z, 0-9, '.', '_' and '-' are allowed", | |
| 68 | + )); | |
| 69 | + } | |
| 70 | + check_folded(input, &name)?; | |
| 71 | + if name.ends_with(".wiki") { | |
| 72 | + return Err(invalid(input, "must not end with .wiki")); | |
| 73 | + } | |
| 74 | + Ok(name) | |
| 75 | +} | |
| 76 | + | |
| 77 | +/// The repo name a wiki transport path names: `name.wiki` or `name.wiki.git` gives `name`. `None` | |
| 78 | +/// for any other name. Repo names never end in `.wiki`, so the mapping is unambiguous. | |
| 79 | +pub fn wiki_base(path_name: &str) -> Option<&str> { | |
| 80 | + let base = path_name.strip_suffix(".git").unwrap_or(path_name); | |
| 81 | + base.strip_suffix(".wiki").filter(|b| !b.is_empty()) | |
| 82 | +} | |
| 83 | + | |
| 84 | +/// Validates an owner (user) name. Returns `(display, folded)`: the NFKC-normalized input with | |
| 85 | +/// its case kept, and the lowercase form used for uniqueness and lookup. | |
| 86 | +/// Display characters are `[A-Za-z0-9._-]`; the folded form must pass the same rules as | |
| 87 | +/// `validate_name`. | |
| 88 | +pub fn validate_username(input: &str) -> Result<(String, String)> { | |
| 89 | + let display: String = input.nfkc().collect(); | |
| 90 | + if !display | |
| 91 | + .chars() | |
| 92 | + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) | |
| 93 | + { | |
| 94 | + return Err(invalid( | |
| 95 | + input, | |
| 96 | + "only A-Z, a-z, 0-9, '.', '_' and '-' are allowed", | |
| 97 | + )); | |
| 98 | + } | |
| 99 | + let folded = display.to_ascii_lowercase(); | |
| 100 | + check_folded(input, &folded)?; | |
| 101 | + Ok((display, folded)) | |
| 102 | +} | |
| 103 | + | |
| 104 | +/// Message for a username change that alters more than capitalization. | |
| 105 | +pub const CAPITALIZATION_ONLY: &str = "Only the capitalization can be changed."; | |
| 106 | + | |
| 107 | +/// Validates `new_display` and checks that it only changes the casing of `current`. | |
| 108 | +/// Returns the display form to store. | |
| 109 | +pub fn capitalization_change(current: &str, new_display: &str) -> Result<String> { | |
| 110 | + let (display, folded) = validate_username(new_display)?; | |
| 111 | + if folded != normalize_name(current) { | |
| 112 | + return Err(Error::Invalid(CAPITALIZATION_ONLY.into())); | |
| 113 | + } | |
| 114 | + Ok(display) | |
| 115 | +} | |
| 116 | + | |
| 117 | +fn invalid(input: &str, why: &str) -> Error { | |
| 118 | + Error::InvalidName(format!("{input:?}: {why}")) | |
| 119 | +} | |
| 120 | + | |
| 121 | +/// Length, leading/trailing, `..`, and reserved-name rules, applied to a folded name. | |
| 122 | +fn check_folded(input: &str, name: &str) -> Result<()> { | |
| 123 | + if !(1..=64).contains(&name.chars().count()) { | |
| 124 | + return Err(invalid(input, "length must be 1 to 64 characters")); | |
| 125 | + } | |
| 126 | + if name.starts_with(['-', '.']) { | |
| 127 | + return Err(invalid(input, "must not start with '-' or '.'")); | |
| 128 | + } | |
| 129 | + if name.ends_with(".git") || name.ends_with(".atom") { | |
| 130 | + return Err(invalid(input, "must not end with .git or .atom")); | |
| 131 | + } | |
| 132 | + if name.contains("..") { | |
| 133 | + return Err(invalid(input, "must not contain '..'")); | |
| 134 | + } | |
| 135 | + if RESERVED.contains(&name) { | |
| 136 | + return Err(invalid(input, "name is reserved")); | |
| 137 | + } | |
| 138 | + Ok(()) | |
| 139 | +} | |
| 140 | + | |
| 141 | +#[cfg(test)] | |
| 142 | +mod tests { | |
| 143 | + use super::*; | |
| 144 | + | |
| 145 | + #[test] | |
| 146 | + fn normalizes_nfkc_then_lowercase() { | |
| 147 | + assert_eq!(normalize_name("ABC"), "abc"); | |
| 148 | + assert_eq!(normalize_name("Repo.Name"), "repo.name"); | |
| 149 | + // Fullwidth 'fi' ligature folds to "fi" under NFKC. | |
| 150 | + assert_eq!(normalize_name("file"), "file"); | |
| 151 | + } | |
| 152 | + | |
| 153 | + #[test] | |
| 154 | + fn accepts_valid_names_and_returns_folded_form() { | |
| 155 | + assert_eq!(validate_name("Alice").unwrap(), "alice"); | |
| 156 | + assert_eq!(validate_name("my-repo_2.x").unwrap(), "my-repo_2.x"); | |
| 157 | + assert_eq!(validate_name("a").unwrap(), "a"); | |
| 158 | + assert_eq!(validate_name(&"a".repeat(64)).unwrap().len(), 64); | |
| 159 | + assert_eq!(validate_name("x.gitx").unwrap(), "x.gitx"); | |
| 160 | + } | |
| 161 | + | |
| 162 | + #[test] | |
| 163 | + fn rejects_invalid_names() { | |
| 164 | + let bad = [ | |
| 165 | + "".to_string(), | |
| 166 | + "a".repeat(65), | |
| 167 | + "has space".into(), | |
| 168 | + "slash/name".into(), | |
| 169 | + "-leading".into(), | |
| 170 | + ".leading".into(), | |
| 171 | + "trailing.git".into(), | |
| 172 | + "TRAILING.GIT".into(), | |
| 173 | + "x.atom".into(), | |
| 174 | + "a..b".into(), | |
| 175 | + "é".into(), | |
| 176 | + "api".into(), | |
| 177 | + "Settings".into(), | |
| 178 | + "-".into(), | |
| 179 | + ".".into(), | |
| 180 | + "..".into(), | |
| 181 | + ]; | |
| 182 | + for name in bad { | |
| 183 | + assert!( | |
| 184 | + matches!(validate_name(&name), Err(Error::InvalidName(_))), | |
| 185 | + "expected rejection for {name:?}" | |
| 186 | + ); | |
| 187 | + } | |
| 188 | + } | |
| 189 | + | |
| 190 | + #[test] | |
| 191 | + fn username_keeps_display_case_and_folds_key() { | |
| 192 | + assert_eq!( | |
| 193 | + validate_username("Tahinli").unwrap(), | |
| 194 | + ("Tahinli".to_string(), "tahinli".to_string()) | |
| 195 | + ); | |
| 196 | + assert_eq!( | |
| 197 | + validate_username("TAHINLI").unwrap(), | |
| 198 | + ("TAHINLI".to_string(), "tahinli".to_string()) | |
| 199 | + ); | |
| 200 | + } | |
| 201 | + | |
| 202 | + #[test] | |
| 203 | + fn username_rejects_reserved_and_invalid() { | |
| 204 | + for bad in [ | |
| 205 | + "ADMIN", | |
| 206 | + "Settings", | |
| 207 | + "Ta hinli", | |
| 208 | + "Ünal", | |
| 209 | + "Tahinli.GIT", | |
| 210 | + "-Tahinli", | |
| 211 | + "a..B", | |
| 212 | + "", | |
| 213 | + ] { | |
| 214 | + assert!( | |
| 215 | + matches!(validate_username(bad), Err(Error::InvalidName(_))), | |
| 216 | + "expected rejection for {bad:?}" | |
| 217 | + ); | |
| 218 | + } | |
| 219 | + } | |
| 220 | + | |
| 221 | + #[test] | |
| 222 | + fn reserved_list_contains_route_names() { | |
| 223 | + for r in [ | |
| 224 | + "api", "admin", "register", "explore", "orgs", "about", "git", | |
| 225 | + ] { | |
| 226 | + assert!(RESERVED.contains(&r)); | |
| 227 | + } | |
| 228 | + } | |
| 229 | +} | |
| 230 | + | |
| 231 | +#[cfg(test)] | |
| 232 | +mod wiki_name_tests { | |
| 233 | + use super::*; | |
| 234 | + | |
| 235 | + #[test] | |
| 236 | + fn repo_names_may_not_end_in_wiki() { | |
| 237 | + assert!(matches!( | |
| 238 | + validate_name("proj.wiki"), | |
| 239 | + Err(Error::InvalidName(_)) | |
| 240 | + )); | |
| 241 | + assert!(validate_name("wikiproj").is_ok()); | |
| 242 | + } | |
| 243 | + | |
| 244 | + #[test] | |
| 245 | + fn wiki_transport_names_map_to_the_repo() { | |
| 246 | + assert_eq!(wiki_base("proj.wiki.git"), Some("proj")); | |
| 247 | + assert_eq!(wiki_base("proj.wiki"), Some("proj")); | |
| 248 | + assert_eq!(wiki_base("proj.git"), None); | |
| 249 | + assert_eq!(wiki_base("proj"), None); | |
| 250 | + assert_eq!(wiki_base(".wiki"), None); | |
| 251 | + } | |
| 252 | +} |
crates/forge-core/src/notifications.rs| @@ -0,0 +1,137 @@ | ||
| 1 | +//! Notification models: repo watch levels, issue subscriptions and inbox entries. | |
| 2 | + | |
| 3 | +use crate::model::Id; | |
| 4 | + | |
| 5 | +/// How much activity a user hears about in a repo. A user with no row is `Participating`. | |
| 6 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 7 | +pub enum WatchLevel { | |
| 8 | + /// All activity in the repo. | |
| 9 | + Watching, | |
| 10 | + /// Only the issues and pulls the user is subscribed to, and mentions. | |
| 11 | + Participating, | |
| 12 | + /// Nothing from the repo, even subscribed threads. Mentions still notify. | |
| 13 | + Ignoring, | |
| 14 | +} | |
| 15 | + | |
| 16 | +impl WatchLevel { | |
| 17 | + pub fn as_str(self) -> &'static str { | |
| 18 | + match self { | |
| 19 | + WatchLevel::Watching => "watching", | |
| 20 | + WatchLevel::Participating => "participating", | |
| 21 | + WatchLevel::Ignoring => "ignoring", | |
| 22 | + } | |
| 23 | + } | |
| 24 | + | |
| 25 | + pub fn parse(s: &str) -> Option<WatchLevel> { | |
| 26 | + match s { | |
| 27 | + "watching" => Some(WatchLevel::Watching), | |
| 28 | + "participating" => Some(WatchLevel::Participating), | |
| 29 | + "ignoring" => Some(WatchLevel::Ignoring), | |
| 30 | + _ => None, | |
| 31 | + } | |
| 32 | + } | |
| 33 | +} | |
| 34 | + | |
| 35 | +/// State of an issue subscription row. | |
| 36 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 37 | +pub enum SubState { | |
| 38 | + Subscribed, | |
| 39 | + /// Explicit opt-out. Auto-subscribe never replaces it. | |
| 40 | + Ignored, | |
| 41 | +} | |
| 42 | + | |
| 43 | +impl SubState { | |
| 44 | + pub fn as_str(self) -> &'static str { | |
| 45 | + match self { | |
| 46 | + SubState::Subscribed => "subscribed", | |
| 47 | + SubState::Ignored => "ignored", | |
| 48 | + } | |
| 49 | + } | |
| 50 | + | |
| 51 | + pub fn parse(s: &str) -> Option<SubState> { | |
| 52 | + match s { | |
| 53 | + "subscribed" => Some(SubState::Subscribed), | |
| 54 | + "ignored" => Some(SubState::Ignored), | |
| 55 | + _ => None, | |
| 56 | + } | |
| 57 | + } | |
| 58 | +} | |
| 59 | + | |
| 60 | +/// Why a user is notified. The stronger reason wins while a thread is unread. | |
| 61 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 62 | +pub enum Reason { | |
| 63 | + Mention, | |
| 64 | + Assign, | |
| 65 | + ReviewRequested, | |
| 66 | + Author, | |
| 67 | + Comment, | |
| 68 | + StateChange, | |
| 69 | + Subscribed, | |
| 70 | + Watching, | |
| 71 | +} | |
| 72 | + | |
| 73 | +impl Reason { | |
| 74 | + pub fn as_str(self) -> &'static str { | |
| 75 | + match self { | |
| 76 | + Reason::Mention => "mention", | |
| 77 | + Reason::Assign => "assign", | |
| 78 | + Reason::ReviewRequested => "review_requested", | |
| 79 | + Reason::Author => "author", | |
| 80 | + Reason::Comment => "comment", | |
| 81 | + Reason::StateChange => "state_change", | |
| 82 | + Reason::Subscribed => "subscribed", | |
| 83 | + Reason::Watching => "watching", | |
| 84 | + } | |
| 85 | + } | |
| 86 | + | |
| 87 | + pub fn parse(s: &str) -> Option<Reason> { | |
| 88 | + match s { | |
| 89 | + "mention" => Some(Reason::Mention), | |
| 90 | + "assign" => Some(Reason::Assign), | |
| 91 | + "review_requested" => Some(Reason::ReviewRequested), | |
| 92 | + "author" => Some(Reason::Author), | |
| 93 | + "comment" => Some(Reason::Comment), | |
| 94 | + "state_change" => Some(Reason::StateChange), | |
| 95 | + "subscribed" => Some(Reason::Subscribed), | |
| 96 | + "watching" => Some(Reason::Watching), | |
| 97 | + _ => None, | |
| 98 | + } | |
| 99 | + } | |
| 100 | + | |
| 101 | + /// Strength, higher is stronger: mention > assign > review_requested > author > comment > | |
| 102 | + /// state_change > subscribed > watching. | |
| 103 | + pub fn rank(self) -> u8 { | |
| 104 | + match self { | |
| 105 | + Reason::Mention => 8, | |
| 106 | + Reason::Assign => 7, | |
| 107 | + Reason::ReviewRequested => 6, | |
| 108 | + Reason::Author => 5, | |
| 109 | + Reason::Comment => 4, | |
| 110 | + Reason::StateChange => 3, | |
| 111 | + Reason::Subscribed => 2, | |
| 112 | + Reason::Watching => 1, | |
| 113 | + } | |
| 114 | + } | |
| 115 | +} | |
| 116 | + | |
| 117 | +/// One inbox row joined with its repo, issue or pull, and the last actor. | |
| 118 | +#[derive(Debug, Clone, PartialEq, Eq)] | |
| 119 | +pub struct Notification { | |
| 120 | + pub id: Id, | |
| 121 | + pub repo_id: Id, | |
| 122 | + pub repo_owner: String, | |
| 123 | + pub repo_name: String, | |
| 124 | + pub issue_id: Id, | |
| 125 | + pub number: i64, | |
| 126 | + /// True for pull requests (`issues.kind = 'pull'`). | |
| 127 | + pub is_pull: bool, | |
| 128 | + pub title: String, | |
| 129 | + /// `open` or `closed`. | |
| 130 | + pub issue_state: String, | |
| 131 | + pub reason: Reason, | |
| 132 | + pub last_activity: String, | |
| 133 | + /// `None` when the actor was deleted without a ghost reassignment or the activity had none. | |
| 134 | + pub last_actor_name: Option<String>, | |
| 135 | + pub unread: bool, | |
| 136 | + pub updated_at: String, | |
| 137 | +} |
crates/forge-core/src/password.rs| @@ -0,0 +1,52 @@ | ||
| 1 | +//! Password hashing with argon2id. | |
| 2 | + | |
| 3 | +use argon2::Argon2; | |
| 4 | +use argon2::password_hash::phc::PasswordHash; | |
| 5 | +use argon2::password_hash::{PasswordHasher, PasswordVerifier}; | |
| 6 | + | |
| 7 | +use crate::error::{Error, Result}; | |
| 8 | + | |
| 9 | +/// Hashes `pw` with argon2id (default parameters) and a random salt. Returns a PHC string. | |
| 10 | +pub fn hash_password(pw: &str) -> Result<String> { | |
| 11 | + // The trait method draws a fresh salt from the OS RNG. | |
| 12 | + Argon2::default() | |
| 13 | + .hash_password(pw.as_bytes()) | |
| 14 | + .map(|h| h.to_string()) | |
| 15 | + .map_err(|e| Error::Crypto(format!("argon2: {e}"))) | |
| 16 | +} | |
| 17 | + | |
| 18 | +/// Returns true only if `pw` matches the PHC string `hash`. Any error yields false. | |
| 19 | +pub fn verify_password(pw: &str, hash: &str) -> bool { | |
| 20 | + let Ok(parsed) = PasswordHash::new(hash) else { | |
| 21 | + return false; | |
| 22 | + }; | |
| 23 | + Argon2::default() | |
| 24 | + .verify_password(pw.as_bytes(), &parsed) | |
| 25 | + .is_ok() | |
| 26 | +} | |
| 27 | + | |
| 28 | +#[cfg(test)] | |
| 29 | +mod tests { | |
| 30 | + use super::*; | |
| 31 | + | |
| 32 | + #[test] | |
| 33 | + fn hash_then_verify() { | |
| 34 | + let hash = hash_password("correct horse").unwrap(); | |
| 35 | + assert!(hash.starts_with("$argon2id$")); | |
| 36 | + assert!(verify_password("correct horse", &hash)); | |
| 37 | + assert!(!verify_password("wrong", &hash)); | |
| 38 | + } | |
| 39 | + | |
| 40 | + #[test] | |
| 41 | + fn salts_differ_between_hashes() { | |
| 42 | + let a = hash_password("pw").unwrap(); | |
| 43 | + let b = hash_password("pw").unwrap(); | |
| 44 | + assert_ne!(a, b); | |
| 45 | + } | |
| 46 | + | |
| 47 | + #[test] | |
| 48 | + fn verify_is_false_on_malformed_hash() { | |
| 49 | + assert!(!verify_password("pw", "not a phc string")); | |
| 50 | + assert!(!verify_password("pw", "")); | |
| 51 | + } | |
| 52 | +} |
crates/forge-core/src/protection.rs| @@ -0,0 +1,373 @@ | ||
| 1 | +//! Branch protection rules: pattern syntax, validation, matching and effective-rule resolution. | |
| 2 | + | |
| 3 | +use crate::error::{Error, Result}; | |
| 4 | + | |
| 5 | +/// Longest accepted pattern, in bytes. | |
| 6 | +pub const MAX_PATTERN_BYTES: usize = 200; | |
| 7 | +/// Largest accepted `required_approvals`. | |
| 8 | +pub const MAX_REQUIRED_APPROVALS: u32 = 20; | |
| 9 | + | |
| 10 | +#[derive(Debug, Clone, PartialEq)] | |
| 11 | +pub struct BranchRule { | |
| 12 | + pub id: String, | |
| 13 | + pub repo_id: String, | |
| 14 | + pub pattern: String, | |
| 15 | + pub allow_force_push: bool, | |
| 16 | + pub allow_deletion: bool, | |
| 17 | + /// Approvals from Write-or-higher reviewers that a pull request needs before it can merge. | |
| 18 | + pub required_approvals: u32, | |
| 19 | + /// A changes-requested review from a Write-or-higher reviewer blocks the merge. | |
| 20 | + pub block_on_changes_requested: bool, | |
| 21 | + /// The pull request head must contain the base tip before it can merge. | |
| 22 | + pub require_up_to_date: bool, | |
| 23 | + /// Direct pushes to the branch are rejected for everyone but repo admins. Merges through | |
| 24 | + /// pull requests are unaffected. | |
| 25 | + pub restrict_push: bool, | |
| 26 | + pub created_at: String, | |
| 27 | +} | |
| 28 | + | |
| 29 | +/// The settings a rule saves, without its identity. | |
| 30 | +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] | |
| 31 | +pub struct RuleFlags { | |
| 32 | + pub allow_force_push: bool, | |
| 33 | + pub allow_deletion: bool, | |
| 34 | + pub required_approvals: u32, | |
| 35 | + pub block_on_changes_requested: bool, | |
| 36 | + pub require_up_to_date: bool, | |
| 37 | + pub restrict_push: bool, | |
| 38 | +} | |
| 39 | + | |
| 40 | +impl RuleFlags { | |
| 41 | + /// Only the force-push and deletion flags set; the merge and push rules off. | |
| 42 | + pub fn basic(allow_force_push: bool, allow_deletion: bool) -> Self { | |
| 43 | + Self { | |
| 44 | + allow_force_push, | |
| 45 | + allow_deletion, | |
| 46 | + ..Self::default() | |
| 47 | + } | |
| 48 | + } | |
| 49 | +} | |
| 50 | + | |
| 51 | +/// The settings that apply to one branch. `Default` is the unprotected branch. | |
| 52 | +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] | |
| 53 | +pub struct Protection { | |
| 54 | + pub allow_force_push: bool, | |
| 55 | + pub allow_deletion: bool, | |
| 56 | + pub required_approvals: u32, | |
| 57 | + pub block_on_changes_requested: bool, | |
| 58 | + pub require_up_to_date: bool, | |
| 59 | + pub restrict_push: bool, | |
| 60 | +} | |
| 61 | + | |
| 62 | +/// Rejects `required_approvals` above `MAX_REQUIRED_APPROVALS`. | |
| 63 | +pub fn validate_required_approvals(n: u32) -> Result<()> { | |
| 64 | + if n > MAX_REQUIRED_APPROVALS { | |
| 65 | + return Err(Error::Invalid(format!( | |
| 66 | + "required approvals must be between 0 and {MAX_REQUIRED_APPROVALS}" | |
| 67 | + ))); | |
| 68 | + } | |
| 69 | + Ok(()) | |
| 70 | +} | |
| 71 | + | |
| 72 | +/// Pattern syntax: a literal branch name, `*` matches any run of chars except `/`, `**` matches anything | |
| 73 | +/// including `/`. Max 200 bytes, it must be a valid branch name otherwise (no `..`, no leading `-`, no | |
| 74 | +/// control or space chars, no `~^:?[\`). The pattern must not include the `refs/heads/` prefix. | |
| 75 | +pub fn validate_pattern(p: &str) -> Result<()> { | |
| 76 | + let invalid = |why: &str| Err(Error::Invalid(format!("branch pattern {why}"))); | |
| 77 | + if p.is_empty() { | |
| 78 | + return invalid("is empty"); | |
| 79 | + } | |
| 80 | + if p.len() > MAX_PATTERN_BYTES { | |
| 81 | + return invalid(&format!("is longer than {MAX_PATTERN_BYTES} bytes")); | |
| 82 | + } | |
| 83 | + if p.starts_with("refs/heads/") { | |
| 84 | + return invalid("must not include the refs/heads/ prefix"); | |
| 85 | + } | |
| 86 | + if p == "@" || p.contains("@{") { | |
| 87 | + return invalid("contains a reserved sequence"); | |
| 88 | + } | |
| 89 | + if p.contains("..") { | |
| 90 | + return invalid("must not contain '..'"); | |
| 91 | + } | |
| 92 | + if p.starts_with('-') { | |
| 93 | + return invalid("must not start with '-'"); | |
| 94 | + } | |
| 95 | + if p.starts_with('/') || p.ends_with('/') || p.contains("//") { | |
| 96 | + return invalid("has an empty path segment"); | |
| 97 | + } | |
| 98 | + if p.split('/') | |
| 99 | + .any(|seg| seg.starts_with('.') || seg.ends_with(".lock")) | |
| 100 | + { | |
| 101 | + return invalid("has a path segment that starts with '.' or ends with '.lock'"); | |
| 102 | + } | |
| 103 | + if p.ends_with('.') { | |
| 104 | + return invalid("must not end with '.'"); | |
| 105 | + } | |
| 106 | + if p.chars() | |
| 107 | + .any(|c| c.is_control() || c.is_whitespace() || "~^:?[\\".contains(c)) | |
| 108 | + { | |
| 109 | + return invalid("contains a forbidden character"); | |
| 110 | + } | |
| 111 | + Ok(()) | |
| 112 | +} | |
| 113 | + | |
| 114 | +#[derive(Clone, Copy, PartialEq, Eq)] | |
| 115 | +enum Tok { | |
| 116 | + Char(char), | |
| 117 | + /// `*`: any run of chars except `/`. | |
| 118 | + Star, | |
| 119 | + /// `**`: any run of chars, `/` included. | |
| 120 | + AnyStar, | |
| 121 | +} | |
| 122 | + | |
| 123 | +fn tokenize(pattern: &str) -> Vec<Tok> { | |
| 124 | + let mut out = Vec::new(); | |
| 125 | + let mut chars = pattern.chars().peekable(); | |
| 126 | + while let Some(c) = chars.next() { | |
| 127 | + if c != '*' { | |
| 128 | + out.push(Tok::Char(c)); | |
| 129 | + continue; | |
| 130 | + } | |
| 131 | + let mut run = 1; | |
| 132 | + while chars.peek() == Some(&'*') { | |
| 133 | + chars.next(); | |
| 134 | + run += 1; | |
| 135 | + } | |
| 136 | + out.push(if run >= 2 { Tok::AnyStar } else { Tok::Star }); | |
| 137 | + } | |
| 138 | + out | |
| 139 | +} | |
| 140 | + | |
| 141 | +/// Whether `branch` matches `pattern`. Dynamic programming over (token, char) positions, so the | |
| 142 | +/// cost is bounded by pattern length times branch length. | |
| 143 | +pub fn pattern_matches(pattern: &str, branch: &str) -> bool { | |
| 144 | + let chars: Vec<char> = branch.chars().collect(); | |
| 145 | + let n = chars.len(); | |
| 146 | + // reach[j]: the tokens consumed so far can match chars[..j]. | |
| 147 | + let mut reach = vec![false; n + 1]; | |
| 148 | + reach[0] = true; | |
| 149 | + for tok in tokenize(pattern) { | |
| 150 | + let mut next = vec![false; n + 1]; | |
| 151 | + match tok { | |
| 152 | + Tok::Char(c) => { | |
| 153 | + for j in 0..n { | |
| 154 | + next[j + 1] = reach[j] && chars[j] == c; | |
| 155 | + } | |
| 156 | + } | |
| 157 | + Tok::Star => { | |
| 158 | + next[0] = reach[0]; | |
| 159 | + for j in 1..=n { | |
| 160 | + next[j] = reach[j] || (next[j - 1] && chars[j - 1] != '/'); | |
| 161 | + } | |
| 162 | + } | |
| 163 | + Tok::AnyStar => { | |
| 164 | + next[0] = reach[0]; | |
| 165 | + for j in 1..=n { | |
| 166 | + next[j] = reach[j] || next[j - 1]; | |
| 167 | + } | |
| 168 | + } | |
| 169 | + } | |
| 170 | + reach = next; | |
| 171 | + } | |
| 172 | + reach[n] | |
| 173 | +} | |
| 174 | + | |
| 175 | +fn is_literal(pattern: &str) -> bool { | |
| 176 | + !pattern.contains('*') | |
| 177 | +} | |
| 178 | + | |
| 179 | +/// Rules for `branch` (short name, without refs/heads/): | |
| 180 | +/// 1. If any rule matches: the most specific one wins. A literal (no `*`) beats a glob; among globs the | |
| 181 | +/// longest pattern wins, and ties go to the earliest created_at. | |
| 182 | +/// 2. Otherwise, if `branch == default_branch`: built-in protection {force_push: false, deletion: false}. | |
| 183 | +/// 3. Otherwise: unprotected {force_push: true, deletion: true}. | |
| 184 | +pub fn effective(rules: &[BranchRule], default_branch: &str, branch: &str) -> Protection { | |
| 185 | + let best = rules | |
| 186 | + .iter() | |
| 187 | + .filter(|r| pattern_matches(&r.pattern, branch)) | |
| 188 | + .min_by(|a, b| { | |
| 189 | + // Literal first, then longer pattern, then earliest creation, then pattern text | |
| 190 | + // so the result never depends on the order of `rules`. | |
| 191 | + is_literal(&b.pattern) | |
| 192 | + .cmp(&is_literal(&a.pattern)) | |
| 193 | + .then_with(|| b.pattern.len().cmp(&a.pattern.len())) | |
| 194 | + .then_with(|| a.created_at.cmp(&b.created_at)) | |
| 195 | + .then_with(|| a.pattern.cmp(&b.pattern)) | |
| 196 | + }); | |
| 197 | + if let Some(rule) = best { | |
| 198 | + return Protection { | |
| 199 | + allow_force_push: rule.allow_force_push, | |
| 200 | + allow_deletion: rule.allow_deletion, | |
| 201 | + required_approvals: rule.required_approvals, | |
| 202 | + block_on_changes_requested: rule.block_on_changes_requested, | |
| 203 | + require_up_to_date: rule.require_up_to_date, | |
| 204 | + restrict_push: rule.restrict_push, | |
| 205 | + }; | |
| 206 | + } | |
| 207 | + if branch == default_branch { | |
| 208 | + Protection { | |
| 209 | + allow_force_push: false, | |
| 210 | + allow_deletion: false, | |
| 211 | + ..Protection::default() | |
| 212 | + } | |
| 213 | + } else { | |
| 214 | + Protection { | |
| 215 | + allow_force_push: true, | |
| 216 | + allow_deletion: true, | |
| 217 | + ..Protection::default() | |
| 218 | + } | |
| 219 | + } | |
| 220 | +} | |
| 221 | + | |
| 222 | +#[cfg(test)] | |
| 223 | +mod tests { | |
| 224 | + use super::*; | |
| 225 | + | |
| 226 | + fn rule(pattern: &str, force: bool, deletion: bool, created: &str) -> BranchRule { | |
| 227 | + BranchRule { | |
| 228 | + id: format!("id-{pattern}"), | |
| 229 | + repo_id: "r".into(), | |
| 230 | + pattern: pattern.into(), | |
| 231 | + allow_force_push: force, | |
| 232 | + allow_deletion: deletion, | |
| 233 | + required_approvals: 0, | |
| 234 | + block_on_changes_requested: false, | |
| 235 | + require_up_to_date: false, | |
| 236 | + restrict_push: false, | |
| 237 | + created_at: created.into(), | |
| 238 | + } | |
| 239 | + } | |
| 240 | + | |
| 241 | + fn flags(force: bool, deletion: bool) -> Protection { | |
| 242 | + Protection { | |
| 243 | + allow_force_push: force, | |
| 244 | + allow_deletion: deletion, | |
| 245 | + ..Protection::default() | |
| 246 | + } | |
| 247 | + } | |
| 248 | + | |
| 249 | + #[test] | |
| 250 | + fn pattern_matches_cases() { | |
| 251 | + assert!(pattern_matches("main", "main")); | |
| 252 | + assert!(!pattern_matches("main", "main2")); | |
| 253 | + assert!(pattern_matches("release/*", "release/1.0")); | |
| 254 | + assert!(!pattern_matches("release/*", "release/a/b")); | |
| 255 | + assert!(pattern_matches("release/**", "release/a/b")); | |
| 256 | + assert!(pattern_matches("*", "feature")); | |
| 257 | + assert!(!pattern_matches("*", "a/b")); | |
| 258 | + assert!(pattern_matches("**", "anything/at/all")); | |
| 259 | + assert!(pattern_matches("**", "x")); | |
| 260 | + assert!(!pattern_matches("", "x")); | |
| 261 | + assert!(pattern_matches("a*b", "ab")); | |
| 262 | + assert!(pattern_matches("a*b", "axxb")); | |
| 263 | + assert!(!pattern_matches("a*b", "a/b")); | |
| 264 | + } | |
| 265 | + | |
| 266 | + #[test] | |
| 267 | + fn validate_pattern_rejects_bad_input() { | |
| 268 | + for bad in [ | |
| 269 | + "refs/heads/main", | |
| 270 | + "a..b", | |
| 271 | + "-x", | |
| 272 | + "a b", | |
| 273 | + "a~b", | |
| 274 | + "", | |
| 275 | + &"a".repeat(201), | |
| 276 | + ] { | |
| 277 | + assert!(validate_pattern(bad).is_err(), "{bad:?} should be rejected"); | |
| 278 | + } | |
| 279 | + } | |
| 280 | + | |
| 281 | + #[test] | |
| 282 | + fn validate_pattern_accepts_good_input() { | |
| 283 | + for good in [ | |
| 284 | + "main", | |
| 285 | + "release/*", | |
| 286 | + "release/**", | |
| 287 | + "**", | |
| 288 | + "a".repeat(200).as_str(), | |
| 289 | + ] { | |
| 290 | + assert!( | |
| 291 | + validate_pattern(good).is_ok(), | |
| 292 | + "{good:?} should be accepted" | |
| 293 | + ); | |
| 294 | + } | |
| 295 | + } | |
| 296 | + | |
| 297 | + #[test] | |
| 298 | + fn effective_without_rules_protects_only_default() { | |
| 299 | + let none: &[BranchRule] = &[]; | |
| 300 | + assert_eq!(effective(none, "main", "main"), flags(false, false)); | |
| 301 | + assert_eq!(effective(none, "main", "feature"), flags(true, true)); | |
| 302 | + } | |
| 303 | + | |
| 304 | + #[test] | |
| 305 | + fn effective_literal_allows_force_push_on_main() { | |
| 306 | + let rules = [rule("main", true, false, "1")]; | |
| 307 | + assert_eq!(effective(&rules, "main", "main"), flags(true, false)); | |
| 308 | + } | |
| 309 | + | |
| 310 | + #[test] | |
| 311 | + fn effective_literal_beats_glob() { | |
| 312 | + let rules = [rule("*", false, false, "1"), rule("main", true, true, "2")]; | |
| 313 | + assert_eq!(effective(&rules, "main", "main"), flags(true, true)); | |
| 314 | + } | |
| 315 | + | |
| 316 | + #[test] | |
| 317 | + fn effective_longer_glob_wins() { | |
| 318 | + // Both `*` and `rel*` match `release`; the longer `rel*` wins even though `*` is older. | |
| 319 | + let rules = [rule("*", true, true, "0"), rule("rel*", false, false, "1")]; | |
| 320 | + assert_eq!(effective(&rules, "main", "release"), flags(false, false)); | |
| 321 | + // `release/**` is longer than `release/*`, so it wins for nested names. | |
| 322 | + let rules = [ | |
| 323 | + rule("release/*", true, true, "1"), | |
| 324 | + rule("release/**", false, false, "2"), | |
| 325 | + ]; | |
| 326 | + assert_eq!( | |
| 327 | + effective(&rules, "main", "release/a/b"), | |
| 328 | + flags(false, false) | |
| 329 | + ); | |
| 330 | + } | |
| 331 | + | |
| 332 | + #[test] | |
| 333 | + fn effective_carries_merge_and_push_rules() { | |
| 334 | + let mut gated = rule("main", false, false, "1"); | |
| 335 | + gated.required_approvals = 2; | |
| 336 | + gated.block_on_changes_requested = true; | |
| 337 | + gated.require_up_to_date = true; | |
| 338 | + gated.restrict_push = true; | |
| 339 | + let rules = [gated]; | |
| 340 | + assert_eq!( | |
| 341 | + effective(&rules, "main", "main"), | |
| 342 | + Protection { | |
| 343 | + allow_force_push: false, | |
| 344 | + allow_deletion: false, | |
| 345 | + required_approvals: 2, | |
| 346 | + block_on_changes_requested: true, | |
| 347 | + require_up_to_date: true, | |
| 348 | + restrict_push: true, | |
| 349 | + } | |
| 350 | + ); | |
| 351 | + // Unprotected and built-in default branches carry no merge rules. | |
| 352 | + assert_eq!(effective(&rules, "main", "feature").required_approvals, 0); | |
| 353 | + assert!(!effective(&[], "main", "main").restrict_push); | |
| 354 | + } | |
| 355 | + | |
| 356 | + #[test] | |
| 357 | + fn validate_required_approvals_bounds() { | |
| 358 | + assert!(validate_required_approvals(0).is_ok()); | |
| 359 | + assert!(validate_required_approvals(MAX_REQUIRED_APPROVALS).is_ok()); | |
| 360 | + assert!(validate_required_approvals(MAX_REQUIRED_APPROVALS + 1).is_err()); | |
| 361 | + } | |
| 362 | + | |
| 363 | + #[test] | |
| 364 | + fn effective_double_star_deletion_false_protects_every_branch() { | |
| 365 | + let rules = [rule("**", true, false, "1")]; | |
| 366 | + for branch in ["main", "feature", "a/b/c"] { | |
| 367 | + assert!( | |
| 368 | + !effective(&rules, "main", branch).allow_deletion, | |
| 369 | + "{branch} should not allow deletion" | |
| 370 | + ); | |
| 371 | + } | |
| 372 | + } | |
| 373 | +} |
crates/forge-core/src/pulls.rs| @@ -0,0 +1,128 @@ | ||
| 1 | +//! Pull request types. A pull request is an issue with `kind = 'pull'` plus a `pull_requests` | |
| 2 | +//! row. The stored shape is in migrations/0007_pulls.sql; the store functions live in | |
| 3 | +//! store/pulls.rs. | |
| 4 | + | |
| 5 | +use crate::error::{Error, Result}; | |
| 6 | +use crate::issues::Issue; | |
| 7 | +use crate::model::Id; | |
| 8 | + | |
| 9 | +/// Mergeability of a pull request, as last computed. | |
| 10 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 11 | +pub enum Mergeable { | |
| 12 | + Unknown, | |
| 13 | + Clean, | |
| 14 | + Conflict, | |
| 15 | + Merged, | |
| 16 | + UpToDate, | |
| 17 | +} | |
| 18 | + | |
| 19 | +impl Mergeable { | |
| 20 | + pub fn as_str(&self) -> &'static str { | |
| 21 | + match self { | |
| 22 | + Mergeable::Unknown => "unknown", | |
| 23 | + Mergeable::Clean => "clean", | |
| 24 | + Mergeable::Conflict => "conflict", | |
| 25 | + Mergeable::Merged => "merged", | |
| 26 | + Mergeable::UpToDate => "up_to_date", | |
| 27 | + } | |
| 28 | + } | |
| 29 | +} | |
| 30 | + | |
| 31 | +impl std::str::FromStr for Mergeable { | |
| 32 | + type Err = Error; | |
| 33 | + | |
| 34 | + fn from_str(s: &str) -> Result<Self> { | |
| 35 | + match s { | |
| 36 | + "unknown" => Ok(Mergeable::Unknown), | |
| 37 | + "clean" => Ok(Mergeable::Clean), | |
| 38 | + "conflict" => Ok(Mergeable::Conflict), | |
| 39 | + "merged" => Ok(Mergeable::Merged), | |
| 40 | + "up_to_date" => Ok(Mergeable::UpToDate), | |
| 41 | + other => Err(Error::Invalid(format!("mergeable {other:?}"))), | |
| 42 | + } | |
| 43 | + } | |
| 44 | +} | |
| 45 | + | |
| 46 | +#[derive(Debug, Clone, PartialEq)] | |
| 47 | +pub struct PullRequest { | |
| 48 | + /// The backing issue row (`kind = 'pull'`): number, title, state, author and so on. | |
| 49 | + pub issue: Issue, | |
| 50 | + pub base_branch: String, | |
| 51 | + /// `None` once the head repo has been deleted. | |
| 52 | + pub head_repo_id: Option<Id>, | |
| 53 | + pub head_branch: String, | |
| 54 | + /// Last known head commit (also `refs/pull/N/head` in the base repo). | |
| 55 | + pub head_oid: String, | |
| 56 | + /// Base branch tip when mergeability was last computed. | |
| 57 | + pub base_oid: String, | |
| 58 | + pub merge_base: Option<String>, | |
| 59 | + pub mergeable: Mergeable, | |
| 60 | + pub conflict_files: Vec<String>, | |
| 61 | + pub merged_at: Option<String>, | |
| 62 | + pub merged_by: Option<Id>, | |
| 63 | + pub merge_commit: Option<String>, | |
| 64 | +} | |
| 65 | + | |
| 66 | +/// How a pull request is merged into its base branch. | |
| 67 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 68 | +pub enum MergeStyle { | |
| 69 | + Merge, | |
| 70 | + Squash, | |
| 71 | + FastForward, | |
| 72 | +} | |
| 73 | + | |
| 74 | +impl MergeStyle { | |
| 75 | + /// The value used in forms and URLs. | |
| 76 | + pub fn as_str(&self) -> &'static str { | |
| 77 | + match self { | |
| 78 | + MergeStyle::Merge => "merge", | |
| 79 | + MergeStyle::Squash => "squash", | |
| 80 | + MergeStyle::FastForward => "ff", | |
| 81 | + } | |
| 82 | + } | |
| 83 | +} | |
| 84 | + | |
| 85 | +impl std::str::FromStr for MergeStyle { | |
| 86 | + type Err = Error; | |
| 87 | + | |
| 88 | + fn from_str(s: &str) -> Result<Self> { | |
| 89 | + match s { | |
| 90 | + "merge" => Ok(MergeStyle::Merge), | |
| 91 | + "squash" => Ok(MergeStyle::Squash), | |
| 92 | + "ff" => Ok(MergeStyle::FastForward), | |
| 93 | + other => Err(Error::Invalid(format!("merge style {other:?}"))), | |
| 94 | + } | |
| 95 | + } | |
| 96 | +} | |
| 97 | + | |
| 98 | +#[cfg(test)] | |
| 99 | +mod tests { | |
| 100 | + use super::*; | |
| 101 | + | |
| 102 | + #[test] | |
| 103 | + fn mergeable_round_trips() { | |
| 104 | + for m in [ | |
| 105 | + Mergeable::Unknown, | |
| 106 | + Mergeable::Clean, | |
| 107 | + Mergeable::Conflict, | |
| 108 | + Mergeable::Merged, | |
| 109 | + Mergeable::UpToDate, | |
| 110 | + ] { | |
| 111 | + assert_eq!(m.as_str().parse::<Mergeable>().ok(), Some(m)); | |
| 112 | + } | |
| 113 | + assert!("Clean".parse::<Mergeable>().is_err()); | |
| 114 | + } | |
| 115 | + | |
| 116 | + #[test] | |
| 117 | + fn merge_style_round_trips() { | |
| 118 | + for s in [ | |
| 119 | + MergeStyle::Merge, | |
| 120 | + MergeStyle::Squash, | |
| 121 | + MergeStyle::FastForward, | |
| 122 | + ] { | |
| 123 | + assert_eq!(s.as_str().parse::<MergeStyle>().ok(), Some(s)); | |
| 124 | + } | |
| 125 | + assert_eq!(MergeStyle::FastForward.as_str(), "ff"); | |
| 126 | + assert!("rebase".parse::<MergeStyle>().is_err()); | |
| 127 | + } | |
| 128 | +} |
crates/forge-core/src/raw_token.rs| @@ -0,0 +1,142 @@ | ||
| 1 | +//! Signed tokens for private raw-file URLs (see docs/m2-interfaces.md). | |
| 2 | +//! | |
| 3 | +//! Token format: `"<expires>.<base64url-nopad(hmac_sha256)>"`, where the MAC covers | |
| 4 | +//! `"raw-v1:" + repo_id + ":" + expires`. | |
| 5 | + | |
| 6 | +use base64::prelude::{BASE64_URL_SAFE_NO_PAD, Engine as _}; | |
| 7 | +use hmac::{Hmac, KeyInit, Mac}; | |
| 8 | +use sha2::Sha256; | |
| 9 | + | |
| 10 | +type HmacSha256 = Hmac<Sha256>; | |
| 11 | + | |
| 12 | +const MAC_LEN: usize = 32; | |
| 13 | + | |
| 14 | +fn mac_for(key: &[u8; 32], repo_id: &str, expires_unix: i64) -> HmacSha256 { | |
| 15 | + // HMAC accepts keys of any length, so construction with a 32-byte key cannot fail. | |
| 16 | + let mut mac = | |
| 17 | + <HmacSha256 as KeyInit>::new_from_slice(key).expect("HMAC accepts any key length"); | |
| 18 | + mac.update(b"raw-v1:"); | |
| 19 | + mac.update(repo_id.as_bytes()); | |
| 20 | + mac.update(b":"); | |
| 21 | + mac.update(expires_unix.to_string().as_bytes()); | |
| 22 | + mac | |
| 23 | +} | |
| 24 | + | |
| 25 | +/// Signs access to `repo_id`'s raw files until `expires_unix` (seconds since the epoch). | |
| 26 | +pub fn sign_raw(key: &[u8; 32], repo_id: &str, expires_unix: i64) -> String { | |
| 27 | + let tag = mac_for(key, repo_id, expires_unix).finalize().into_bytes(); | |
| 28 | + format!("{expires_unix}.{}", BASE64_URL_SAFE_NO_PAD.encode(tag)) | |
| 29 | +} | |
| 30 | + | |
| 31 | +/// Verifies a token for `repo_id` at time `now_unix`. Rejects malformed input, expired | |
| 32 | +/// tokens (`now >= expires`) and bad MACs. The MAC comparison is constant time. | |
| 33 | +pub fn verify_raw(key: &[u8; 32], token: &str, repo_id: &str, now_unix: i64) -> bool { | |
| 34 | + let Some((expires_str, mac_str)) = token.split_once('.') else { | |
| 35 | + return false; | |
| 36 | + }; | |
| 37 | + // Strict canonical decimal: no sign, no leading zeros, no whitespace. | |
| 38 | + let Ok(expires) = expires_str.parse::<i64>() else { | |
| 39 | + return false; | |
| 40 | + }; | |
| 41 | + if expires.to_string() != expires_str { | |
| 42 | + return false; | |
| 43 | + } | |
| 44 | + if now_unix >= expires { | |
| 45 | + return false; | |
| 46 | + } | |
| 47 | + let Ok(tag) = BASE64_URL_SAFE_NO_PAD.decode(mac_str) else { | |
| 48 | + return false; | |
| 49 | + }; | |
| 50 | + if tag.len() != MAC_LEN { | |
| 51 | + return false; | |
| 52 | + } | |
| 53 | + mac_for(key, repo_id, expires).verify_slice(&tag).is_ok() | |
| 54 | +} | |
| 55 | + | |
| 56 | +#[cfg(test)] | |
| 57 | +mod tests { | |
| 58 | + use super::*; | |
| 59 | + | |
| 60 | + const KEY: [u8; 32] = [7; 32]; | |
| 61 | + const OTHER_KEY: [u8; 32] = [9; 32]; | |
| 62 | + const NOW: i64 = 1_700_000_000; | |
| 63 | + | |
| 64 | + #[test] | |
| 65 | + fn round_trip() { | |
| 66 | + let token = sign_raw(&KEY, "repo-1", NOW + 60); | |
| 67 | + assert!(verify_raw(&KEY, &token, "repo-1", NOW)); | |
| 68 | + } | |
| 69 | + | |
| 70 | + #[test] | |
| 71 | + fn token_has_expected_shape() { | |
| 72 | + let token = sign_raw(&KEY, "repo-1", NOW + 60); | |
| 73 | + let (expires, mac) = token.split_once('.').unwrap(); | |
| 74 | + assert_eq!(expires, (NOW + 60).to_string()); | |
| 75 | + assert_eq!(BASE64_URL_SAFE_NO_PAD.decode(mac).unwrap().len(), MAC_LEN); | |
| 76 | + } | |
| 77 | + | |
| 78 | + #[test] | |
| 79 | + fn expired_is_rejected() { | |
| 80 | + let token = sign_raw(&KEY, "repo-1", NOW); | |
| 81 | + // now == expires is already expired. | |
| 82 | + assert!(!verify_raw(&KEY, &token, "repo-1", NOW)); | |
| 83 | + assert!(!verify_raw(&KEY, &token, "repo-1", NOW + 1)); | |
| 84 | + assert!(verify_raw(&KEY, &token, "repo-1", NOW - 1)); | |
| 85 | + } | |
| 86 | + | |
| 87 | + #[test] | |
| 88 | + fn wrong_repo_is_rejected() { | |
| 89 | + let token = sign_raw(&KEY, "repo-1", NOW + 60); | |
| 90 | + assert!(!verify_raw(&KEY, &token, "repo-2", NOW)); | |
| 91 | + } | |
| 92 | + | |
| 93 | + #[test] | |
| 94 | + fn tampered_mac_is_rejected() { | |
| 95 | + let token = sign_raw(&KEY, "repo-1", NOW + 60); | |
| 96 | + let (expires, mac) = token.split_once('.').unwrap(); | |
| 97 | + let mut tag = BASE64_URL_SAFE_NO_PAD.decode(mac).unwrap(); | |
| 98 | + tag[0] ^= 1; | |
| 99 | + let tampered = format!("{expires}.{}", BASE64_URL_SAFE_NO_PAD.encode(tag)); | |
| 100 | + assert!(!verify_raw(&KEY, &tampered, "repo-1", NOW)); | |
| 101 | + } | |
| 102 | + | |
| 103 | + #[test] | |
| 104 | + fn extended_expiry_is_rejected() { | |
| 105 | + let token = sign_raw(&KEY, "repo-1", NOW + 60); | |
| 106 | + let (_, mac) = token.split_once('.').unwrap(); | |
| 107 | + let forged = format!("{}.{mac}", NOW + 3600); | |
| 108 | + assert!(!verify_raw(&KEY, &forged, "repo-1", NOW)); | |
| 109 | + } | |
| 110 | + | |
| 111 | + #[test] | |
| 112 | + fn garbage_is_rejected() { | |
| 113 | + let good = sign_raw(&KEY, "repo-1", NOW + 60); | |
| 114 | + let (expires, mac) = good.split_once('.').unwrap(); | |
| 115 | + let inputs = [ | |
| 116 | + String::new(), | |
| 117 | + "garbage".to_owned(), | |
| 118 | + ".".to_owned(), | |
| 119 | + expires.to_string(), | |
| 120 | + format!("{expires}."), | |
| 121 | + format!(".{mac}"), | |
| 122 | + format!("{expires}.{mac}.extra"), | |
| 123 | + format!("+{expires}.{mac}"), | |
| 124 | + format!("{expires}.!!!"), | |
| 125 | + format!("{expires}.{}", BASE64_URL_SAFE_NO_PAD.encode([0u8; 16])), | |
| 126 | + format!("{expires}.{mac}=="), | |
| 127 | + format!("notanumber.{mac}"), | |
| 128 | + ]; | |
| 129 | + for input in &inputs { | |
| 130 | + assert!( | |
| 131 | + !verify_raw(&KEY, input, "repo-1", NOW), | |
| 132 | + "accepted {input:?}" | |
| 133 | + ); | |
| 134 | + } | |
| 135 | + } | |
| 136 | + | |
| 137 | + #[test] | |
| 138 | + fn different_key_is_rejected() { | |
| 139 | + let token = sign_raw(&KEY, "repo-1", NOW + 60); | |
| 140 | + assert!(!verify_raw(&OTHER_KEY, &token, "repo-1", NOW)); | |
| 141 | + } | |
| 142 | +} |
crates/forge-core/src/reactions.rs| @@ -0,0 +1,112 @@ | ||
| 1 | +//! Emoji reactions: the fixed set a user can put on an issue, a pull request or a comment, and | |
| 2 | +//! the summaries the pages show. Stored as the short name (`+1`, `heart`, ...). | |
| 3 | + | |
| 4 | +use crate::error::{Error, Result}; | |
| 5 | +use crate::model::Id; | |
| 6 | + | |
| 7 | +/// The fixed reaction set, in the order the pages list them. | |
| 8 | +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] | |
| 9 | +pub enum Reaction { | |
| 10 | + PlusOne, | |
| 11 | + MinusOne, | |
| 12 | + Laugh, | |
| 13 | + Hooray, | |
| 14 | + Confused, | |
| 15 | + Heart, | |
| 16 | + Rocket, | |
| 17 | + Eyes, | |
| 18 | +} | |
| 19 | + | |
| 20 | +impl Reaction { | |
| 21 | + /// Every reaction, in display order. | |
| 22 | + pub const ALL: [Reaction; 8] = [ | |
| 23 | + Reaction::PlusOne, | |
| 24 | + Reaction::MinusOne, | |
| 25 | + Reaction::Laugh, | |
| 26 | + Reaction::Hooray, | |
| 27 | + Reaction::Confused, | |
| 28 | + Reaction::Heart, | |
| 29 | + Reaction::Rocket, | |
| 30 | + Reaction::Eyes, | |
| 31 | + ]; | |
| 32 | + | |
| 33 | + /// The short name stored in the database and sent in forms. | |
| 34 | + pub fn name(self) -> &'static str { | |
| 35 | + match self { | |
| 36 | + Reaction::PlusOne => "+1", | |
| 37 | + Reaction::MinusOne => "-1", | |
| 38 | + Reaction::Laugh => "laugh", | |
| 39 | + Reaction::Hooray => "hooray", | |
| 40 | + Reaction::Confused => "confused", | |
| 41 | + Reaction::Heart => "heart", | |
| 42 | + Reaction::Rocket => "rocket", | |
| 43 | + Reaction::Eyes => "eyes", | |
| 44 | + } | |
| 45 | + } | |
| 46 | + | |
| 47 | + /// The glyph shown for the reaction. | |
| 48 | + pub fn emoji(self) -> &'static str { | |
| 49 | + match self { | |
| 50 | + Reaction::PlusOne => "\u{1f44d}", | |
| 51 | + Reaction::MinusOne => "\u{1f44e}", | |
| 52 | + Reaction::Laugh => "\u{1f604}", | |
| 53 | + Reaction::Hooray => "\u{1f389}", | |
| 54 | + Reaction::Confused => "\u{1f615}", | |
| 55 | + Reaction::Heart => "\u{2764}\u{fe0f}", | |
| 56 | + Reaction::Rocket => "\u{1f680}", | |
| 57 | + Reaction::Eyes => "\u{1f440}", | |
| 58 | + } | |
| 59 | + } | |
| 60 | + | |
| 61 | + /// The reaction with this short name, or `None` for anything outside the set. | |
| 62 | + pub fn parse(name: &str) -> Option<Reaction> { | |
| 63 | + Reaction::ALL.into_iter().find(|r| r.name() == name) | |
| 64 | + } | |
| 65 | +} | |
| 66 | + | |
| 67 | +impl std::str::FromStr for Reaction { | |
| 68 | + type Err = Error; | |
| 69 | + | |
| 70 | + fn from_str(s: &str) -> Result<Self> { | |
| 71 | + Reaction::parse(s).ok_or_else(|| Error::Invalid(format!("unknown reaction: {s}"))) | |
| 72 | + } | |
| 73 | +} | |
| 74 | + | |
| 75 | +/// What a reaction is attached to. Pull requests are issue rows, so they use `Issue`. | |
| 76 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 77 | +pub enum ReactionTarget { | |
| 78 | + Issue, | |
| 79 | + Comment, | |
| 80 | + /// A discussion (store/discussions.rs). | |
| 81 | + Discussion, | |
| 82 | + /// A discussion comment or reply. | |
| 83 | + DiscussionComment, | |
| 84 | +} | |
| 85 | + | |
| 86 | +impl ReactionTarget { | |
| 87 | + /// The `target_kind` column value. | |
| 88 | + pub fn as_str(self) -> &'static str { | |
| 89 | + match self { | |
| 90 | + ReactionTarget::Issue => "issue", | |
| 91 | + ReactionTarget::Comment => "comment", | |
| 92 | + ReactionTarget::Discussion => "discussion", | |
| 93 | + ReactionTarget::DiscussionComment => "discussion_comment", | |
| 94 | + } | |
| 95 | + } | |
| 96 | +} | |
| 97 | + | |
| 98 | +/// One reaction on one target: how many users chose it, whether the viewer did, and the first | |
| 99 | +/// usernames (at most ten, earliest reaction first). | |
| 100 | +#[derive(Debug, Clone, PartialEq, Eq)] | |
| 101 | +pub struct ReactionSummary { | |
| 102 | + pub reaction: Reaction, | |
| 103 | + pub count: usize, | |
| 104 | + pub reacted_by_viewer: bool, | |
| 105 | + pub users: Vec<String>, | |
| 106 | +} | |
| 107 | + | |
| 108 | +/// Usernames kept per summary. | |
| 109 | +pub const SUMMARY_USERS: usize = 10; | |
| 110 | + | |
| 111 | +/// Summaries of one target, keyed by the target id. | |
| 112 | +pub type ReactionMap = std::collections::HashMap<Id, Vec<ReactionSummary>>; |
crates/forge-core/src/releases.rs| @@ -0,0 +1,112 @@ | ||
| 1 | +//! Releases: a tag with a title, notes and optional downloadable assets. The stored shape is in | |
| 2 | +//! migrations/0020_releases.sql; the store functions live in store/releases.rs. | |
| 3 | +//! | |
| 4 | +//! A release names a git tag. The tag itself lives in git, so deleting a release keeps it. | |
| 5 | +//! Asset bytes live in the blob store under `assets/<asset id>`; only metadata is stored here. | |
| 6 | + | |
| 7 | +use crate::model::Id; | |
| 8 | + | |
| 9 | +/// Longest release title, in characters. | |
| 10 | +pub const MAX_TITLE_CHARS: usize = 200; | |
| 11 | +/// Longest release body, in characters. | |
| 12 | +pub const MAX_BODY_CHARS: usize = 65_536; | |
| 13 | +/// Longest asset file name, in bytes. Names are ASCII, so bytes and characters agree. | |
| 14 | +pub const MAX_ASSET_NAME_LEN: usize = 200; | |
| 15 | + | |
| 16 | +/// A release. `published_at` is set once it is first published and stays set; `draft` decides | |
| 17 | +/// visibility. Drafts are shown only to people with Write on the repo. | |
| 18 | +#[derive(Debug, Clone, PartialEq, Eq)] | |
| 19 | +pub struct Release { | |
| 20 | + pub id: Id, | |
| 21 | + pub repo_id: Id, | |
| 22 | + pub tag: String, | |
| 23 | + /// The commit the tag names when the release was made. | |
| 24 | + pub target_oid: String, | |
| 25 | + pub title: String, | |
| 26 | + pub body: String, | |
| 27 | + pub draft: bool, | |
| 28 | + pub prerelease: bool, | |
| 29 | + pub author_id: Id, | |
| 30 | + pub author_name: String, | |
| 31 | + pub created_at: String, | |
| 32 | + pub published_at: Option<String>, | |
| 33 | +} | |
| 34 | + | |
| 35 | +/// A file attached to a release. `download_count` counts completed starts of downloads. | |
| 36 | +#[derive(Debug, Clone, PartialEq, Eq)] | |
| 37 | +pub struct ReleaseAsset { | |
| 38 | + pub id: Id, | |
| 39 | + pub release_id: Id, | |
| 40 | + pub name: String, | |
| 41 | + pub size: u64, | |
| 42 | + pub sha256: String, | |
| 43 | + pub content_type: String, | |
| 44 | + pub download_count: u64, | |
| 45 | + pub uploader_id: Id, | |
| 46 | + pub created_at: String, | |
| 47 | +} | |
| 48 | + | |
| 49 | +/// Input for `Store::create_release`. | |
| 50 | +#[derive(Debug, Clone, Copy)] | |
| 51 | +pub struct NewRelease<'a> { | |
| 52 | + pub repo_id: &'a str, | |
| 53 | + pub tag: &'a str, | |
| 54 | + pub target_oid: &'a str, | |
| 55 | + pub title: &'a str, | |
| 56 | + pub body: &'a str, | |
| 57 | + pub draft: bool, | |
| 58 | + pub prerelease: bool, | |
| 59 | + pub author_id: &'a str, | |
| 60 | +} | |
| 61 | + | |
| 62 | +/// The editable fields of a release. The tag and its target never change. | |
| 63 | +#[derive(Debug, Clone, Copy)] | |
| 64 | +pub struct ReleaseEdit<'a> { | |
| 65 | + pub title: &'a str, | |
| 66 | + pub body: &'a str, | |
| 67 | + pub draft: bool, | |
| 68 | + pub prerelease: bool, | |
| 69 | +} | |
| 70 | + | |
| 71 | +/// Input for `Store::add_asset`. The blob is already written under `assets/<id>`. | |
| 72 | +#[derive(Debug, Clone, Copy)] | |
| 73 | +pub struct NewAsset<'a> { | |
| 74 | + pub id: &'a str, | |
| 75 | + pub release_id: &'a str, | |
| 76 | + pub name: &'a str, | |
| 77 | + pub size: u64, | |
| 78 | + pub sha256: &'a str, | |
| 79 | + pub content_type: &'a str, | |
| 80 | + pub uploader_id: &'a str, | |
| 81 | +} | |
| 82 | + | |
| 83 | +/// True for a file name that is safe in a `Content-Disposition` header and a URL segment: 1 to | |
| 84 | +/// [`MAX_ASSET_NAME_LEN`] of ASCII letters, digits, `.`, `-`, `_` or `+`, not starting with a dot. | |
| 85 | +pub fn valid_asset_name(name: &str) -> bool { | |
| 86 | + !name.is_empty() | |
| 87 | + && name.len() <= MAX_ASSET_NAME_LEN | |
| 88 | + && !name.starts_with('.') | |
| 89 | + && name | |
| 90 | + .bytes() | |
| 91 | + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_' | b'+')) | |
| 92 | +} | |
| 93 | + | |
| 94 | +/// The content type stored for an upload: a `type/subtype` token pair, or | |
| 95 | +/// `application/octet-stream` when the client sent nothing usable. | |
| 96 | +pub fn clean_content_type(raw: Option<&str>) -> String { | |
| 97 | + let fallback = "application/octet-stream"; | |
| 98 | + let Some(raw) = raw.map(str::trim) else { | |
| 99 | + return fallback.to_owned(); | |
| 100 | + }; | |
| 101 | + let ok_token = |s: &str| { | |
| 102 | + !s.is_empty() | |
| 103 | + && s.bytes() | |
| 104 | + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'+' | b'_')) | |
| 105 | + }; | |
| 106 | + match raw.split_once('/') { | |
| 107 | + Some((main, sub)) if ok_token(main) && ok_token(sub) => { | |
| 108 | + format!("{}/{}", main.to_ascii_lowercase(), sub.to_ascii_lowercase()) | |
| 109 | + } | |
| 110 | + _ => fallback.to_owned(), | |
| 111 | + } | |
| 112 | +} |
crates/forge-core/src/reviews.rs| @@ -0,0 +1,123 @@ | ||
| 1 | +//! Pull request reviews and line comments. The stored shape is in migrations/0008_reviews.sql; | |
| 2 | +//! the store functions live in store/reviews.rs. | |
| 3 | + | |
| 4 | +use crate::error::{Error, Result}; | |
| 5 | +use crate::model::Id; | |
| 6 | + | |
| 7 | +/// State of a review. `Pending` is a draft visible only to its author. | |
| 8 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 9 | +pub enum ReviewState { | |
| 10 | + Pending, | |
| 11 | + Commented, | |
| 12 | + Approved, | |
| 13 | + ChangesRequested, | |
| 14 | + Dismissed, | |
| 15 | +} | |
| 16 | + | |
| 17 | +impl ReviewState { | |
| 18 | + pub fn as_str(&self) -> &'static str { | |
| 19 | + match self { | |
| 20 | + ReviewState::Pending => "pending", | |
| 21 | + ReviewState::Commented => "commented", | |
| 22 | + ReviewState::Approved => "approved", | |
| 23 | + ReviewState::ChangesRequested => "changes_requested", | |
| 24 | + ReviewState::Dismissed => "dismissed", | |
| 25 | + } | |
| 26 | + } | |
| 27 | +} | |
| 28 | + | |
| 29 | +impl std::str::FromStr for ReviewState { | |
| 30 | + type Err = Error; | |
| 31 | + | |
| 32 | + fn from_str(s: &str) -> Result<Self> { | |
| 33 | + match s { | |
| 34 | + "pending" => Ok(ReviewState::Pending), | |
| 35 | + "commented" => Ok(ReviewState::Commented), | |
| 36 | + "approved" => Ok(ReviewState::Approved), | |
| 37 | + "changes_requested" => Ok(ReviewState::ChangesRequested), | |
| 38 | + "dismissed" => Ok(ReviewState::Dismissed), | |
| 39 | + other => Err(Error::Invalid(format!("review state {other:?}"))), | |
| 40 | + } | |
| 41 | + } | |
| 42 | +} | |
| 43 | + | |
| 44 | +/// Which side of the diff a comment is on: `Old` for deleted lines, `New` for added and context | |
| 45 | +/// lines. | |
| 46 | +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] | |
| 47 | +pub enum Side { | |
| 48 | + Old, | |
| 49 | + New, | |
| 50 | +} | |
| 51 | + | |
| 52 | +impl Side { | |
| 53 | + pub fn as_str(&self) -> &'static str { | |
| 54 | + match self { | |
| 55 | + Side::Old => "old", | |
| 56 | + Side::New => "new", | |
| 57 | + } | |
| 58 | + } | |
| 59 | +} | |
| 60 | + | |
| 61 | +impl std::str::FromStr for Side { | |
| 62 | + type Err = Error; | |
| 63 | + | |
| 64 | + fn from_str(s: &str) -> Result<Self> { | |
| 65 | + match s { | |
| 66 | + "old" => Ok(Side::Old), | |
| 67 | + "new" => Ok(Side::New), | |
| 68 | + other => Err(Error::Invalid(format!("diff side {other:?}"))), | |
| 69 | + } | |
| 70 | + } | |
| 71 | +} | |
| 72 | + | |
| 73 | +#[derive(Debug, Clone, PartialEq)] | |
| 74 | +pub struct Review { | |
| 75 | + pub id: Id, | |
| 76 | + pub issue_id: Id, | |
| 77 | + pub author_id: Id, | |
| 78 | + pub author_name: String, | |
| 79 | + pub state: ReviewState, | |
| 80 | + pub body: String, | |
| 81 | + pub commit_oid: String, | |
| 82 | + pub dismissed_reason: Option<String>, | |
| 83 | + /// The state the review had before it was dismissed (`Approved` or `ChangesRequested`). None | |
| 84 | + /// for reviews dismissed before this was recorded, and for reviews that were not dismissed. | |
| 85 | + pub dismissed_from: Option<ReviewState>, | |
| 86 | + pub created_at: String, | |
| 87 | + pub submitted_at: Option<String>, | |
| 88 | +} | |
| 89 | + | |
| 90 | +#[derive(Debug, Clone, PartialEq)] | |
| 91 | +pub struct ReviewComment { | |
| 92 | + pub id: Id, | |
| 93 | + pub review_id: Id, | |
| 94 | + pub author_id: Id, | |
| 95 | + pub author_name: String, | |
| 96 | + pub path: String, | |
| 97 | + pub side: Side, | |
| 98 | + pub line: u32, | |
| 99 | + pub commit_oid: String, | |
| 100 | + pub diff_hunk: String, | |
| 101 | + pub reply_to: Option<Id>, | |
| 102 | + pub body: String, | |
| 103 | + pub outdated: bool, | |
| 104 | + pub resolved: bool, | |
| 105 | + pub created_at: String, | |
| 106 | + pub updated_at: String, | |
| 107 | + /// True when the review this comment belongs to is still pending. | |
| 108 | + pub pending: bool, | |
| 109 | +} | |
| 110 | + | |
| 111 | +/// A root comment and its replies, oldest first. | |
| 112 | +#[derive(Debug, Clone, PartialEq)] | |
| 113 | +pub struct Thread { | |
| 114 | + pub root: ReviewComment, | |
| 115 | + pub replies: Vec<ReviewComment>, | |
| 116 | +} | |
| 117 | + | |
| 118 | +/// Who currently approves and who requests changes, by username. | |
| 119 | +#[derive(Debug, Clone, Default, PartialEq, Eq)] | |
| 120 | +pub struct ReviewSummary { | |
| 121 | + pub approved_by: Vec<String>, | |
| 122 | + pub changes_requested_by: Vec<String>, | |
| 123 | +} |
crates/forge-core/src/secret.rs| @@ -0,0 +1,178 @@ | ||
| 1 | +//! Instance key handling and XChaCha20-Poly1305 sealing of secrets at rest. | |
| 2 | + | |
| 3 | +use std::path::Path; | |
| 4 | + | |
| 5 | +use base64::prelude::{BASE64_STANDARD, Engine as _}; | |
| 6 | +use chacha20poly1305::aead::Aead; | |
| 7 | +use chacha20poly1305::{KeyInit, XChaCha20Poly1305, XNonce}; | |
| 8 | + | |
| 9 | +use crate::error::{Error, Result}; | |
| 10 | + | |
| 11 | +const PREFIX: &str = "v1:"; | |
| 12 | +const NONCE_LEN: usize = 24; | |
| 13 | + | |
| 14 | +/// Loads the 32-byte instance key at `path`, creating it (mode 0600) if missing. | |
| 15 | +pub fn load_or_create_key(path: &Path) -> Result<[u8; 32]> { | |
| 16 | + if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) { | |
| 17 | + std::fs::create_dir_all(parent)?; | |
| 18 | + } | |
| 19 | + match create_key(path) { | |
| 20 | + Ok(key) => Ok(key), | |
| 21 | + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => read_key(path), | |
| 22 | + Err(e) => Err(e.into()), | |
| 23 | + } | |
| 24 | +} | |
| 25 | + | |
| 26 | +fn create_key(path: &Path) -> std::io::Result<[u8; 32]> { | |
| 27 | + use std::io::Write; | |
| 28 | + | |
| 29 | + use rand::TryRng; | |
| 30 | + | |
| 31 | + let mut key = [0u8; 32]; | |
| 32 | + rand::rngs::SysRng | |
| 33 | + .try_fill_bytes(&mut key) | |
| 34 | + .map_err(std::io::Error::other)?; | |
| 35 | + | |
| 36 | + let mut options = std::fs::OpenOptions::new(); | |
| 37 | + options.write(true).create_new(true); | |
| 38 | + #[cfg(unix)] | |
| 39 | + { | |
| 40 | + use std::os::unix::fs::OpenOptionsExt; | |
| 41 | + options.mode(0o600); | |
| 42 | + } | |
| 43 | + let mut file = options.open(path)?; | |
| 44 | + let written = file.write_all(&key).and_then(|()| file.sync_all()); | |
| 45 | + if let Err(e) = written { | |
| 46 | + // Do not leave a truncated key behind; the next start would reject it. | |
| 47 | + drop(file); | |
| 48 | + let _ = std::fs::remove_file(path); | |
| 49 | + return Err(e); | |
| 50 | + } | |
| 51 | + Ok(key) | |
| 52 | +} | |
| 53 | + | |
| 54 | +fn read_key(path: &Path) -> Result<[u8; 32]> { | |
| 55 | + #[cfg(unix)] | |
| 56 | + { | |
| 57 | + use std::os::unix::fs::PermissionsExt; | |
| 58 | + let mode = std::fs::metadata(path)?.permissions().mode(); | |
| 59 | + if mode & 0o077 != 0 { | |
| 60 | + return Err(Error::Crypto(format!( | |
| 61 | + "instance key {} is group/world accessible (mode {:o}); expected 0600", | |
| 62 | + path.display(), | |
| 63 | + mode & 0o777 | |
| 64 | + ))); | |
| 65 | + } | |
| 66 | + } | |
| 67 | + let bytes = std::fs::read(path)?; | |
| 68 | + <[u8; 32]>::try_from(bytes.as_slice()).map_err(|_| { | |
| 69 | + Error::Crypto(format!( | |
| 70 | + "instance key {} has length {}, expected 32", | |
| 71 | + path.display(), | |
| 72 | + bytes.len() | |
| 73 | + )) | |
| 74 | + }) | |
| 75 | +} | |
| 76 | + | |
| 77 | +/// Seals `plaintext`. Output format: `"v1:" + base64(nonce || ciphertext)`. | |
| 78 | +/// | |
| 79 | +/// Errors if the cipher rejects the input; the caller must not store plaintext instead. | |
| 80 | +pub fn seal(key: &[u8; 32], plaintext: &[u8]) -> Result<String> { | |
| 81 | + let mut nonce = [0u8; NONCE_LEN]; | |
| 82 | + rand::fill(&mut nonce); | |
| 83 | + let cipher = XChaCha20Poly1305::new(key.into()); | |
| 84 | + let ciphertext = cipher | |
| 85 | + .encrypt(&XNonce::from(nonce), plaintext) | |
| 86 | + .map_err(|_| Error::Crypto("sealing failed".into()))?; | |
| 87 | + let mut blob = Vec::with_capacity(NONCE_LEN + ciphertext.len()); | |
| 88 | + blob.extend_from_slice(&nonce); | |
| 89 | + blob.extend_from_slice(&ciphertext); | |
| 90 | + Ok(format!("{PREFIX}{}", BASE64_STANDARD.encode(blob))) | |
| 91 | +} | |
| 92 | + | |
| 93 | +/// Opens a value produced by [`seal`]. | |
| 94 | +pub fn open(key: &[u8; 32], sealed: &str) -> Result<Vec<u8>> { | |
| 95 | + let encoded = sealed | |
| 96 | + .strip_prefix(PREFIX) | |
| 97 | + .ok_or_else(|| Error::Crypto("sealed value has no v1 prefix".into()))?; | |
| 98 | + let blob = BASE64_STANDARD | |
| 99 | + .decode(encoded) | |
| 100 | + .map_err(|_| Error::Crypto("sealed value is not valid base64".into()))?; | |
| 101 | + if blob.len() < NONCE_LEN { | |
| 102 | + return Err(Error::Crypto("sealed value is too short".into())); | |
| 103 | + } | |
| 104 | + let (nonce, ciphertext) = blob.split_at(NONCE_LEN); | |
| 105 | + let nonce = <[u8; NONCE_LEN]>::try_from(nonce) | |
| 106 | + .map_err(|_| Error::Crypto("sealed value has a bad nonce".into()))?; | |
| 107 | + let cipher = XChaCha20Poly1305::new(key.into()); | |
| 108 | + cipher | |
| 109 | + .decrypt(&XNonce::from(nonce), ciphertext) | |
| 110 | + .map_err(|_| Error::Crypto("sealed value failed authentication".into())) | |
| 111 | +} | |
| 112 | + | |
| 113 | +#[cfg(test)] | |
| 114 | +mod tests { | |
| 115 | + use super::*; | |
| 116 | + | |
| 117 | + #[test] | |
| 118 | + fn seal_open_round_trip_and_format() { | |
| 119 | + let key = [7u8; 32]; | |
| 120 | + let sealed = seal(&key, b"hello secret").unwrap(); | |
| 121 | + assert!(sealed.starts_with("v1:")); | |
| 122 | + assert_eq!(open(&key, &sealed).unwrap(), b"hello secret"); | |
| 123 | + // Fresh nonce each time. | |
| 124 | + assert_ne!(sealed, seal(&key, b"hello secret").unwrap()); | |
| 125 | + } | |
| 126 | + | |
| 127 | + #[test] | |
| 128 | + fn open_rejects_wrong_key_tamper_and_garbage() { | |
| 129 | + let key = [7u8; 32]; | |
| 130 | + let other = [8u8; 32]; | |
| 131 | + let sealed = seal(&key, b"data").unwrap(); | |
| 132 | + assert!(open(&other, &sealed).is_err()); | |
| 133 | + let mut tampered = sealed.clone(); | |
| 134 | + let last = tampered.pop().unwrap(); | |
| 135 | + tampered.push(if last == 'A' { 'B' } else { 'A' }); | |
| 136 | + assert!(open(&key, &tampered).is_err()); | |
| 137 | + assert!(open(&key, "data").is_err()); | |
| 138 | + assert!(open(&key, "v1:!!!").is_err()); | |
| 139 | + assert!(open(&key, "v1:AAAA").is_err()); | |
| 140 | + assert!(open(&key, "").is_err()); | |
| 141 | + } | |
| 142 | + | |
| 143 | + #[test] | |
| 144 | + fn key_is_created_with_32_bytes_and_reloaded() { | |
| 145 | + let dir = tempfile::tempdir().unwrap(); | |
| 146 | + let path = dir.path().join("sub").join("instance.key"); | |
| 147 | + let created = load_or_create_key(&path).unwrap(); | |
| 148 | + assert_eq!(std::fs::read(&path).unwrap().len(), 32); | |
| 149 | + assert_eq!(load_or_create_key(&path).unwrap(), created); | |
| 150 | + } | |
| 151 | + | |
| 152 | + #[cfg(unix)] | |
| 153 | + #[test] | |
| 154 | + fn key_file_mode_is_0600_and_loose_modes_are_rejected() { | |
| 155 | + use std::os::unix::fs::PermissionsExt; | |
| 156 | + let dir = tempfile::tempdir().unwrap(); | |
| 157 | + let path = dir.path().join("instance.key"); | |
| 158 | + load_or_create_key(&path).unwrap(); | |
| 159 | + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); | |
| 160 | + assert_eq!(mode & 0o777, 0o600); | |
| 161 | + | |
| 162 | + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).unwrap(); | |
| 163 | + assert!(load_or_create_key(&path).is_err()); | |
| 164 | + } | |
| 165 | + | |
| 166 | + #[test] | |
| 167 | + fn wrong_length_key_is_rejected() { | |
| 168 | + let dir = tempfile::tempdir().unwrap(); | |
| 169 | + let path = dir.path().join("instance.key"); | |
| 170 | + std::fs::write(&path, [1u8; 31]).unwrap(); | |
| 171 | + #[cfg(unix)] | |
| 172 | + { | |
| 173 | + use std::os::unix::fs::PermissionsExt; | |
| 174 | + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); | |
| 175 | + } | |
| 176 | + assert!(matches!(load_or_create_key(&path), Err(Error::Crypto(_)))); | |
| 177 | + } | |
| 178 | +} |
crates/forge-core/src/session.rs| @@ -0,0 +1,32 @@ | ||
| 1 | +//! Session and CSRF token generation. | |
| 2 | + | |
| 3 | +use base64::prelude::{BASE64_URL_SAFE_NO_PAD, Engine as _}; | |
| 4 | + | |
| 5 | +use crate::token::random_32; | |
| 6 | + | |
| 7 | +/// base64url(32 random bytes), used as the cookie value. The DB stores its sha256. | |
| 8 | +pub fn new_session_token() -> String { | |
| 9 | + BASE64_URL_SAFE_NO_PAD.encode(random_32()) | |
| 10 | +} | |
| 11 | + | |
| 12 | +pub fn new_csrf_token() -> String { | |
| 13 | + BASE64_URL_SAFE_NO_PAD.encode(random_32()) | |
| 14 | +} | |
| 15 | + | |
| 16 | +#[cfg(test)] | |
| 17 | +mod tests { | |
| 18 | + use super::*; | |
| 19 | + | |
| 20 | + #[test] | |
| 21 | + fn tokens_are_unique_base64url_of_32_bytes() { | |
| 22 | + let a = new_session_token(); | |
| 23 | + let b = new_csrf_token(); | |
| 24 | + assert_eq!(a.len(), 43); | |
| 25 | + assert_eq!(b.len(), 43); | |
| 26 | + assert_ne!(a, b); | |
| 27 | + assert!( | |
| 28 | + a.bytes() | |
| 29 | + .all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_') | |
| 30 | + ); | |
| 31 | + } | |
| 32 | +} |
crates/forge-core/src/store/account_tokens.rs| @@ -0,0 +1,537 @@ | ||
| 1 | +//! Account mail tokens: password reset, email verification and invites. A token is 32 random | |
| 2 | +//! bytes shown once in the mail link; only its sha256 is stored. Redeeming one is an UPDATE that | |
| 3 | +//! is guarded by `used_at IS NULL` and the expiry, so a link works at most once. Timestamps are | |
| 4 | +//! fixed-width UTC stamps, so expiry is a text comparison. | |
| 5 | + | |
| 6 | +use std::time::Duration; | |
| 7 | + | |
| 8 | +use super::issues::finish; | |
| 9 | +use super::{ | |
| 10 | + Store, col_int, col_opt_text, col_text, db_err, fetch_opt, insert_err, map_user, new_id, | |
| 11 | +}; | |
| 12 | +use crate::error::{Error, Result}; | |
| 13 | +use crate::model::{AccountKind, User}; | |
| 14 | +use crate::names::normalize_name; | |
| 15 | +use crate::time::{now_stamp, stamp_after}; | |
| 16 | +use crate::token::{generate_token, hash_token}; | |
| 17 | + | |
| 18 | +/// How long a password reset link works. | |
| 19 | +pub const RESET_TTL: Duration = Duration::from_secs(60 * 60); | |
| 20 | +/// How long an email verification link works. | |
| 21 | +pub const VERIFY_TTL: Duration = Duration::from_secs(24 * 60 * 60); | |
| 22 | +/// How long an invite link works. | |
| 23 | +pub const INVITE_TTL: Duration = Duration::from_secs(7 * 24 * 60 * 60); | |
| 24 | + | |
| 25 | +impl Store { | |
| 26 | + /// Mints a reset token for `user_id` and drops the user's unused ones, so only the newest | |
| 27 | + /// link works. Returns the plaintext token, which only the mail link carries. | |
| 28 | + pub async fn create_reset_token(&self, user_id: &str) -> Result<String> { | |
| 29 | + let token = generate_token(); | |
| 30 | + let hash = hash_token(&token); | |
| 31 | + let (now, expires) = (now_stamp(), stamp_after(RESET_TTL)); | |
| 32 | + let conn = self.inner.conn.lock().await; | |
| 33 | + let tx = conn.unchecked_transaction().await.map_err(db_err)?; | |
| 34 | + let outcome: Result<()> = async { | |
| 35 | + tx.execute( | |
| 36 | + "DELETE FROM password_reset_tokens WHERE user_id = ?1 AND used_at IS NULL", | |
| 37 | + (user_id.to_owned(),), | |
| 38 | + ) | |
| 39 | + .await | |
| 40 | + .map_err(db_err)?; | |
| 41 | + tx.execute( | |
| 42 | + "INSERT INTO password_reset_tokens (token_hash, user_id, created_at, expires_at) \ | |
| 43 | + VALUES (?1, ?2, ?3, ?4)", | |
| 44 | + (hash, user_id.to_owned(), now, expires), | |
| 45 | + ) | |
| 46 | + .await | |
| 47 | + .map_err(db_err)?; | |
| 48 | + Ok(()) | |
| 49 | + } | |
| 50 | + .await; | |
| 51 | + finish(tx, outcome).await?; | |
| 52 | + Ok(token) | |
| 53 | + } | |
| 54 | + | |
| 55 | + /// Redeems a reset token. Returns the id of its user, or `None` when the token is unknown, | |
| 56 | + /// used or expired. The UPDATE decides, so two redemptions of one link cannot both succeed. | |
| 57 | + pub async fn redeem_reset_token(&self, token: &str) -> Result<Option<String>> { | |
| 58 | + let hash = hash_token(token); | |
| 59 | + let now = now_stamp(); | |
| 60 | + let conn = self.inner.conn.lock().await; | |
| 61 | + let claimed = conn | |
| 62 | + .execute( | |
| 63 | + "UPDATE password_reset_tokens SET used_at = ?2 WHERE token_hash = ?1 \ | |
| 64 | + AND used_at IS NULL AND expires_at > ?2", | |
| 65 | + (hash.clone(), now), | |
| 66 | + ) | |
| 67 | + .await | |
| 68 | + .map_err(db_err)?; | |
| 69 | + if claimed == 0 { | |
| 70 | + return Ok(None); | |
| 71 | + } | |
| 72 | + fetch_opt( | |
| 73 | + &conn, | |
| 74 | + "SELECT user_id FROM password_reset_tokens WHERE token_hash = ?1", | |
| 75 | + (hash,), | |
| 76 | + |row| col_text(row, 0), | |
| 77 | + ) | |
| 78 | + .await | |
| 79 | + } | |
| 80 | + | |
| 81 | + /// True while a reset token is unused and unexpired. Does not consume it. | |
| 82 | + pub async fn reset_token_is_live(&self, token: &str) -> Result<bool> { | |
| 83 | + let conn = self.read_conn().await; | |
| 84 | + let found = fetch_opt( | |
| 85 | + &conn, | |
| 86 | + "SELECT 1 FROM password_reset_tokens WHERE token_hash = ?1 AND used_at IS NULL \ | |
| 87 | + AND expires_at > ?2", | |
| 88 | + (hash_token(token), now_stamp()), | |
| 89 | + |row| col_int(row, 0), | |
| 90 | + ) | |
| 91 | + .await?; | |
| 92 | + Ok(found.is_some()) | |
| 93 | + } | |
| 94 | + | |
| 95 | + /// The user whose verified address is `email`, compared without case. Unverified addresses | |
| 96 | + /// never match, so a reset mail only goes to an address the account has proven it owns. | |
| 97 | + pub async fn verified_user_by_email(&self, email: &str) -> Result<Option<User>> { | |
| 98 | + let conn = self.read_conn().await; | |
| 99 | + fetch_opt( | |
| 100 | + &conn, | |
| 101 | + "SELECT id, username, email, password_hash, is_admin, created_at, about, timezone, \ | |
| 102 | + (SELECT hash FROM avatars WHERE avatars.user_id = users.id) AS avatar_hash, \ | |
| 103 | + theme, language, kind, email_notifications FROM users WHERE LOWER(email) = LOWER(?1) \ | |
| 104 | + AND email_verified_at IS NOT NULL AND kind = 'user' LIMIT 1", | |
| 105 | + (email.to_owned(),), | |
| 106 | + map_user, | |
| 107 | + ) | |
| 108 | + .await | |
| 109 | + } | |
| 110 | + | |
| 111 | + /// Mints a verification token for `address` of `user_id`. Returns the plaintext token. | |
| 112 | + pub async fn create_verify_token(&self, user_id: &str, address: &str) -> Result<String> { | |
| 113 | + let token = generate_token(); | |
| 114 | + let hash = hash_token(&token); | |
| 115 | + let (now, expires) = (now_stamp(), stamp_after(VERIFY_TTL)); | |
| 116 | + let conn = self.inner.conn.lock().await; | |
| 117 | + let tx = conn.unchecked_transaction().await.map_err(db_err)?; | |
| 118 | + let outcome: Result<()> = async { | |
| 119 | + tx.execute( | |
| 120 | + "DELETE FROM email_verify_tokens WHERE user_id = ?1 AND used_at IS NULL", | |
| 121 | + (user_id.to_owned(),), | |
| 122 | + ) | |
| 123 | + .await | |
| 124 | + .map_err(db_err)?; | |
| 125 | + tx.execute( | |
| 126 | + "INSERT INTO email_verify_tokens (token_hash, user_id, email, created_at, expires_at) \ | |
| 127 | + VALUES (?1, ?2, ?3, ?4, ?5)", | |
| 128 | + (hash, user_id.to_owned(), address.to_owned(), now, expires), | |
| 129 | + ) | |
| 130 | + .await | |
| 131 | + .map_err(db_err)?; | |
| 132 | + Ok(()) | |
| 133 | + } | |
| 134 | + .await; | |
| 135 | + finish(tx, outcome).await?; | |
| 136 | + Ok(token) | |
| 137 | + } | |
| 138 | + | |
| 139 | + /// Redeems a verification token and marks the address verified, as long as it is still the | |
| 140 | + /// user's address. Returns the user id, or `None` when the token is unusable or stale. | |
| 141 | + pub async fn redeem_verify_token(&self, token: &str) -> Result<Option<String>> { | |
| 142 | + let hash = hash_token(token); | |
| 143 | + let now = now_stamp(); | |
| 144 | + let conn = self.inner.conn.lock().await; | |
| 145 | + let tx = conn.unchecked_transaction().await.map_err(db_err)?; | |
| 146 | + let outcome: Result<Option<String>> = async { | |
| 147 | + let claimed = tx | |
| 148 | + .execute( | |
| 149 | + "UPDATE email_verify_tokens SET used_at = ?2 WHERE token_hash = ?1 \ | |
| 150 | + AND used_at IS NULL AND expires_at > ?2", | |
| 151 | + (hash.clone(), now.clone()), | |
| 152 | + ) | |
| 153 | + .await | |
| 154 | + .map_err(db_err)?; | |
| 155 | + if claimed == 0 { | |
| 156 | + return Ok(None); | |
| 157 | + } | |
| 158 | + let Some((user_id, address)) = fetch_opt( | |
| 159 | + &tx, | |
| 160 | + "SELECT user_id, email FROM email_verify_tokens WHERE token_hash = ?1", | |
| 161 | + (hash,), | |
| 162 | + |row| Ok((col_text(row, 0)?, col_text(row, 1)?)), | |
| 163 | + ) | |
| 164 | + .await? | |
| 165 | + else { | |
| 166 | + return Ok(None); | |
| 167 | + }; | |
| 168 | + let verified = tx | |
| 169 | + .execute( | |
| 170 | + "UPDATE users SET email_verified_at = ?3 WHERE id = ?1 AND email = ?2", | |
| 171 | + (user_id.clone(), address, now), | |
| 172 | + ) | |
| 173 | + .await | |
| 174 | + .map_err(db_err)?; | |
| 175 | + Ok((verified > 0).then_some(user_id)) | |
| 176 | + } | |
| 177 | + .await; | |
| 178 | + finish(tx, outcome).await | |
| 179 | + } | |
| 180 | + | |
| 181 | + /// Clears the verified mark. Called when the address changes, since the new one needs its | |
| 182 | + /// own link. | |
| 183 | + pub async fn clear_email_verified(&self, user_id: &str) -> Result<()> { | |
| 184 | + let conn = self.inner.conn.lock().await; | |
| 185 | + conn.execute( | |
| 186 | + "UPDATE users SET email_verified_at = NULL WHERE id = ?1", | |
| 187 | + (user_id.to_owned(),), | |
| 188 | + ) | |
| 189 | + .await | |
| 190 | + .map_err(db_err)?; | |
| 191 | + Ok(()) | |
| 192 | + } | |
| 193 | + | |
| 194 | + /// Whether the user's current address has been verified. | |
| 195 | + pub async fn email_is_verified(&self, user_id: &str) -> Result<bool> { | |
| 196 | + let conn = self.read_conn().await; | |
| 197 | + let stamp = fetch_opt( | |
| 198 | + &conn, | |
| 199 | + "SELECT email_verified_at FROM users WHERE id = ?1", | |
| 200 | + (user_id.to_owned(),), | |
| 201 | + |row| col_opt_text(row, 0), | |
| 202 | + ) | |
| 203 | + .await?; | |
| 204 | + Ok(stamp.flatten().is_some()) | |
| 205 | + } | |
| 206 | + | |
| 207 | + /// Creates an invite for `address`, dropping earlier unaccepted invites for it. Refused when | |
| 208 | + /// an account already uses the address. Returns the plaintext token. | |
| 209 | + pub async fn create_invite(&self, address: &str, invited_by: &str) -> Result<String> { | |
| 210 | + let token = generate_token(); | |
| 211 | + let hash = hash_token(&token); | |
| 212 | + let (now, expires) = (now_stamp(), stamp_after(INVITE_TTL)); | |
| 213 | + let conn = self.inner.conn.lock().await; | |
| 214 | + let tx = conn.unchecked_transaction().await.map_err(db_err)?; | |
| 215 | + let outcome: Result<()> = async { | |
| 216 | + let taken = fetch_opt( | |
| 217 | + &tx, | |
| 218 | + "SELECT COUNT(*) FROM users WHERE LOWER(email) = LOWER(?1)", | |
| 219 | + (address.to_owned(),), | |
| 220 | + |row| col_int(row, 0), | |
| 221 | + ) | |
| 222 | + .await? | |
| 223 | + .unwrap_or(0); | |
| 224 | + if taken > 0 { | |
| 225 | + return Err(Error::Conflict( | |
| 226 | + "an account already uses that address".into(), | |
| 227 | + )); | |
| 228 | + } | |
| 229 | + tx.execute( | |
| 230 | + "DELETE FROM invites WHERE LOWER(email) = LOWER(?1) AND accepted_at IS NULL", | |
| 231 | + (address.to_owned(),), | |
| 232 | + ) | |
| 233 | + .await | |
| 234 | + .map_err(db_err)?; | |
| 235 | + tx.execute( | |
| 236 | + "INSERT INTO invites (token_hash, email, invited_by, created_at, expires_at) \ | |
| 237 | + VALUES (?1, ?2, ?3, ?4, ?5)", | |
| 238 | + ( | |
| 239 | + hash, | |
| 240 | + address.to_owned(), | |
| 241 | + invited_by.to_owned(), | |
| 242 | + now, | |
| 243 | + expires, | |
| 244 | + ), | |
| 245 | + ) | |
| 246 | + .await | |
| 247 | + .map_err(db_err)?; | |
| 248 | + Ok(()) | |
| 249 | + } | |
| 250 | + .await; | |
| 251 | + finish(tx, outcome).await?; | |
| 252 | + Ok(token) | |
| 253 | + } | |
| 254 | + | |
| 255 | + /// The address an invite is for, while the invite is unaccepted and unexpired. | |
| 256 | + pub async fn invite_email(&self, token: &str) -> Result<Option<String>> { | |
| 257 | + let conn = self.read_conn().await; | |
| 258 | + fetch_opt( | |
| 259 | + &conn, | |
| 260 | + "SELECT email FROM invites WHERE token_hash = ?1 AND accepted_at IS NULL \ | |
| 261 | + AND expires_at > ?2", | |
| 262 | + (hash_token(token), now_stamp()), | |
| 263 | + |row| col_text(row, 0), | |
| 264 | + ) | |
| 265 | + .await | |
| 266 | + } | |
| 267 | + | |
| 268 | + /// Accepts an invite and creates its account in one transaction. The invite's address is | |
| 269 | + /// verified at creation. Returns `None` when the invite is not usable. A taken username is a | |
| 270 | + /// `Conflict`, and the invite stays unused. | |
| 271 | + pub async fn accept_invite( | |
| 272 | + &self, | |
| 273 | + token: &str, | |
| 274 | + username: &str, | |
| 275 | + password_hash: &str, | |
| 276 | + ) -> Result<Option<User>> { | |
| 277 | + let hash = hash_token(token); | |
| 278 | + let now = now_stamp(); | |
| 279 | + let conn = self.inner.conn.lock().await; | |
| 280 | + let tx = conn.unchecked_transaction().await.map_err(db_err)?; | |
| 281 | + let outcome: Result<Option<User>> = async { | |
| 282 | + let Some(address) = fetch_opt( | |
| 283 | + &tx, | |
| 284 | + "SELECT email FROM invites WHERE token_hash = ?1 AND accepted_at IS NULL \ | |
| 285 | + AND expires_at > ?2", | |
| 286 | + (hash.clone(), now.clone()), | |
| 287 | + |row| col_text(row, 0), | |
| 288 | + ) | |
| 289 | + .await? | |
| 290 | + else { | |
| 291 | + return Ok(None); | |
| 292 | + }; | |
| 293 | + let user = User { | |
| 294 | + id: new_id(), | |
| 295 | + username: username.to_owned(), | |
| 296 | + email: Some(address.clone()), | |
| 297 | + password_hash: password_hash.to_owned(), | |
| 298 | + is_admin: false, | |
| 299 | + created_at: now.clone(), | |
| 300 | + about: String::new(), | |
| 301 | + timezone: "UTC".to_owned(), | |
| 302 | + avatar_hash: None, | |
| 303 | + theme: "auto".into(), | |
| 304 | + language: "auto".into(), | |
| 305 | + kind: AccountKind::User, | |
| 306 | + email_notifications: true, | |
| 307 | + }; | |
| 308 | + tx.execute( | |
| 309 | + "INSERT INTO users (id, username, username_folded, email, password_hash, is_admin, \ | |
| 310 | + created_at, email_verified_at) VALUES (?1, ?2, ?3, ?4, ?5, 0, ?6, ?6)", | |
| 311 | + ( | |
| 312 | + user.id.clone(), | |
| 313 | + user.username.clone(), | |
| 314 | + normalize_name(username), | |
| 315 | + address, | |
| 316 | + user.password_hash.clone(), | |
| 317 | + now.clone(), | |
| 318 | + ), | |
| 319 | + ) | |
| 320 | + .await | |
| 321 | + .map_err(|e| insert_err(e, "username is already taken"))?; | |
| 322 | + tx.execute( | |
| 323 | + "UPDATE invites SET accepted_at = ?2, accepted_by = ?3 WHERE token_hash = ?1", | |
| 324 | + (hash, now, user.id.clone()), | |
| 325 | + ) | |
| 326 | + .await | |
| 327 | + .map_err(db_err)?; | |
| 328 | + Ok(Some(user)) | |
| 329 | + } | |
| 330 | + .await; | |
| 331 | + finish(tx, outcome).await | |
| 332 | + } | |
| 333 | +} | |
| 334 | + | |
| 335 | +#[cfg(test)] | |
| 336 | +mod tests { | |
| 337 | + use super::*; | |
| 338 | + | |
| 339 | + async fn store() -> (tempfile::TempDir, Store) { | |
| 340 | + let dir = tempfile::tempdir().expect("tempdir"); | |
| 341 | + let store = Store::open(&dir.path().join("tokens.db")) | |
| 342 | + .await | |
| 343 | + .expect("store"); | |
| 344 | + (dir, store) | |
| 345 | + } | |
| 346 | + | |
| 347 | + async fn alice(store: &Store) -> User { | |
| 348 | + store | |
| 349 | + .create_user("alice", Some("alice@example.com"), "hash", false) | |
| 350 | + .await | |
| 351 | + .expect("user") | |
| 352 | + } | |
| 353 | + | |
| 354 | + /// Moves a token's expiry into the past, the way time passing would. | |
| 355 | + async fn expire(store: &Store, table: &str, token: &str) { | |
| 356 | + let conn = store.inner.conn.lock().await; | |
| 357 | + conn.execute( | |
| 358 | + &format!("UPDATE {table} SET expires_at = ?2 WHERE token_hash = ?1"), | |
| 359 | + (hash_token(token), "2000-01-01T00:00:00.000000Z".to_owned()), | |
| 360 | + ) | |
| 361 | + .await | |
| 362 | + .expect("expire"); | |
| 363 | + } | |
| 364 | + | |
| 365 | + #[tokio::test] | |
| 366 | + async fn reset_token_redeems_once() { | |
| 367 | + let (_dir, store) = store().await; | |
| 368 | + let user = alice(&store).await; | |
| 369 | + let token = store.create_reset_token(&user.id).await.unwrap(); | |
| 370 | + assert!(store.reset_token_is_live(&token).await.unwrap()); | |
| 371 | + assert_eq!( | |
| 372 | + store.redeem_reset_token(&token).await.unwrap(), | |
| 373 | + Some(user.id.clone()) | |
| 374 | + ); | |
| 375 | + assert_eq!(store.redeem_reset_token(&token).await.unwrap(), None); | |
| 376 | + assert!(!store.reset_token_is_live(&token).await.unwrap()); | |
| 377 | + } | |
| 378 | + | |
| 379 | + #[tokio::test] | |
| 380 | + async fn expired_and_unknown_reset_tokens_are_refused() { | |
| 381 | + let (_dir, store) = store().await; | |
| 382 | + let user = alice(&store).await; | |
| 383 | + let token = store.create_reset_token(&user.id).await.unwrap(); | |
| 384 | + expire(&store, "password_reset_tokens", &token).await; | |
| 385 | + assert_eq!(store.redeem_reset_token(&token).await.unwrap(), None); | |
| 386 | + assert_eq!(store.redeem_reset_token("forge_nope").await.unwrap(), None); | |
| 387 | + } | |
| 388 | + | |
| 389 | + #[tokio::test] | |
| 390 | + async fn a_new_reset_token_replaces_the_unused_one() { | |
| 391 | + let (_dir, store) = store().await; | |
| 392 | + let user = alice(&store).await; | |
| 393 | + let first = store.create_reset_token(&user.id).await.unwrap(); | |
| 394 | + let second = store.create_reset_token(&user.id).await.unwrap(); | |
| 395 | + assert_eq!(store.redeem_reset_token(&first).await.unwrap(), None); | |
| 396 | + assert!(store.redeem_reset_token(&second).await.unwrap().is_some()); | |
| 397 | + } | |
| 398 | + | |
| 399 | + #[tokio::test] | |
| 400 | + async fn only_a_verified_address_matches_a_reset_lookup() { | |
| 401 | + let (_dir, store) = store().await; | |
| 402 | + let user = alice(&store).await; | |
| 403 | + assert!( | |
| 404 | + store | |
| 405 | + .verified_user_by_email("ALICE@example.com") | |
| 406 | + .await | |
| 407 | + .unwrap() | |
| 408 | + .is_none(), | |
| 409 | + "unverified address must not match" | |
| 410 | + ); | |
| 411 | + let token = store | |
| 412 | + .create_verify_token(&user.id, "alice@example.com") | |
| 413 | + .await | |
| 414 | + .unwrap(); | |
| 415 | + assert_eq!( | |
| 416 | + store.redeem_verify_token(&token).await.unwrap(), | |
| 417 | + Some(user.id.clone()) | |
| 418 | + ); | |
| 419 | + assert!(store.email_is_verified(&user.id).await.unwrap()); | |
| 420 | + let found = store | |
| 421 | + .verified_user_by_email("ALICE@example.com") | |
| 422 | + .await | |
| 423 | + .unwrap() | |
| 424 | + .expect("verified user"); | |
| 425 | + assert_eq!(found.id, user.id); | |
| 426 | + } | |
| 427 | + | |
| 428 | + #[tokio::test] | |
| 429 | + async fn verify_token_is_single_use_and_refused_after_the_address_changes() { | |
| 430 | + let (_dir, store) = store().await; | |
| 431 | + let user = alice(&store).await; | |
| 432 | + let token = store | |
| 433 | + .create_verify_token(&user.id, "alice@example.com") | |
| 434 | + .await | |
| 435 | + .unwrap(); | |
| 436 | + store | |
| 437 | + .set_email(&user.id, "other@example.com") | |
| 438 | + .await | |
| 439 | + .unwrap(); | |
| 440 | + assert_eq!(store.redeem_verify_token(&token).await.unwrap(), None); | |
| 441 | + assert!(!store.email_is_verified(&user.id).await.unwrap()); | |
| 442 | + | |
| 443 | + let fresh = store | |
| 444 | + .create_verify_token(&user.id, "other@example.com") | |
| 445 | + .await | |
| 446 | + .unwrap(); | |
| 447 | + assert!(store.redeem_verify_token(&fresh).await.unwrap().is_some()); | |
| 448 | + assert_eq!(store.redeem_verify_token(&fresh).await.unwrap(), None); | |
| 449 | + } | |
| 450 | + | |
| 451 | + #[tokio::test] | |
| 452 | + async fn clearing_the_mark_unverifies_the_address() { | |
| 453 | + let (_dir, store) = store().await; | |
| 454 | + let user = alice(&store).await; | |
| 455 | + let token = store | |
| 456 | + .create_verify_token(&user.id, "alice@example.com") | |
| 457 | + .await | |
| 458 | + .unwrap(); | |
| 459 | + store.redeem_verify_token(&token).await.unwrap(); | |
| 460 | + store.clear_email_verified(&user.id).await.unwrap(); | |
| 461 | + assert!(!store.email_is_verified(&user.id).await.unwrap()); | |
| 462 | + } | |
| 463 | + | |
| 464 | + #[tokio::test] | |
| 465 | + async fn invite_for_a_taken_address_is_refused() { | |
| 466 | + let (_dir, store) = store().await; | |
| 467 | + let user = alice(&store).await; | |
| 468 | + let err = store | |
| 469 | + .create_invite("ALICE@example.com", &user.id) | |
| 470 | + .await | |
| 471 | + .expect_err("taken"); | |
| 472 | + assert!(matches!(err, Error::Conflict(_))); | |
| 473 | + } | |
| 474 | + | |
| 475 | + #[tokio::test] | |
| 476 | + async fn invite_creates_one_verified_account_and_is_single_use() { | |
| 477 | + let (_dir, store) = store().await; | |
| 478 | + let admin = alice(&store).await; | |
| 479 | + let token = store | |
| 480 | + .create_invite("bob@example.com", &admin.id) | |
| 481 | + .await | |
| 482 | + .unwrap(); | |
| 483 | + assert_eq!( | |
| 484 | + store.invite_email(&token).await.unwrap().as_deref(), | |
| 485 | + Some("bob@example.com") | |
| 486 | + ); | |
| 487 | + let bob = store | |
| 488 | + .accept_invite(&token, "bob", "hash") | |
| 489 | + .await | |
| 490 | + .unwrap() | |
| 491 | + .expect("account"); | |
| 492 | + assert_eq!(bob.email.as_deref(), Some("bob@example.com")); | |
| 493 | + assert!(store.email_is_verified(&bob.id).await.unwrap()); | |
| 494 | + assert!(store.invite_email(&token).await.unwrap().is_none()); | |
| 495 | + assert!( | |
| 496 | + store | |
| 497 | + .accept_invite(&token, "carol", "hash") | |
| 498 | + .await | |
| 499 | + .unwrap() | |
| 500 | + .is_none() | |
| 501 | + ); | |
| 502 | + } | |
| 503 | + | |
| 504 | + #[tokio::test] | |
| 505 | + async fn a_taken_username_leaves_the_invite_unused() { | |
| 506 | + let (_dir, store) = store().await; | |
| 507 | + let admin = alice(&store).await; | |
| 508 | + let token = store | |
| 509 | + .create_invite("bob@example.com", &admin.id) | |
| 510 | + .await | |
| 511 | + .unwrap(); | |
| 512 | + let err = store | |
| 513 | + .accept_invite(&token, "Alice", "hash") | |
| 514 | + .await | |
| 515 | + .expect_err("taken username"); | |
| 516 | + assert!(matches!(err, Error::Conflict(_))); | |
| 517 | + assert!(store.invite_email(&token).await.unwrap().is_some()); | |
| 518 | + } | |
| 519 | + | |
| 520 | + #[tokio::test] | |
| 521 | + async fn expired_invite_is_refused() { | |
| 522 | + let (_dir, store) = store().await; | |
| 523 | + let admin = alice(&store).await; | |
| 524 | + let token = store | |
| 525 | + .create_invite("bob@example.com", &admin.id) | |
| 526 | + .await | |
| 527 | + .unwrap(); | |
| 528 | + expire(&store, "invites", &token).await; | |
| 529 | + assert!( | |
| 530 | + store | |
| 531 | + .accept_invite(&token, "bob", "hash") | |
| 532 | + .await | |
| 533 | + .unwrap() | |
| 534 | + .is_none() | |
| 535 | + ); | |
| 536 | + } | |
| 537 | +} |
crates/forge-core/src/store/advisories.rs| @@ -0,0 +1,788 @@ | ||
| 1 | +//! Advisory storage: the OSV rows of each ecosystem, the dependencies read from each repo's | |
| 2 | +//! lockfiles, and the alerts that match them. | |
| 3 | +//! | |
| 4 | +//! An alert is keyed by (repo, advisory, ecosystem, package, version). A scan opens alerts for new | |
| 5 | +//! matches, reopens a `fixed` alert whose match came back, and marks an `open` alert `fixed` when | |
| 6 | +//! its dependency is gone. `dismissed` alerts are left alone by scans. | |
| 7 | + | |
| 8 | +use std::collections::{BTreeSet, HashMap}; | |
| 9 | + | |
| 10 | +use turso::{Row, Value}; | |
| 11 | + | |
| 12 | +use super::{Store, col_int, col_text, db_err, fetch_all, fetch_opt, finish}; | |
| 13 | +use crate::advisories::{Advisory, Dependency, Range, affects}; | |
| 14 | +use crate::error::{Error, Result}; | |
| 15 | +use crate::time::now_stamp; | |
| 16 | + | |
| 17 | +/// The state of one alert. | |
| 18 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 19 | +pub enum AlertState { | |
| 20 | + Open, | |
| 21 | + Dismissed, | |
| 22 | + Fixed, | |
| 23 | +} | |
| 24 | + | |
| 25 | +impl AlertState { | |
| 26 | + pub fn as_str(self) -> &'static str { | |
| 27 | + match self { | |
| 28 | + AlertState::Open => "open", | |
| 29 | + AlertState::Dismissed => "dismissed", | |
| 30 | + AlertState::Fixed => "fixed", | |
| 31 | + } | |
| 32 | + } | |
| 33 | + | |
| 34 | + fn parse(raw: &str) -> Result<Self> { | |
| 35 | + match raw { | |
| 36 | + "open" => Ok(AlertState::Open), | |
| 37 | + "dismissed" => Ok(AlertState::Dismissed), | |
| 38 | + "fixed" => Ok(AlertState::Fixed), | |
| 39 | + other => Err(Error::Database(format!("unknown alert state {other:?}"))), | |
| 40 | + } | |
| 41 | + } | |
| 42 | +} | |
| 43 | + | |
| 44 | +/// The identity of one alert within a repo. | |
| 45 | +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] | |
| 46 | +pub struct AlertKey { | |
| 47 | + pub advisory_id: String, | |
| 48 | + pub ecosystem: String, | |
| 49 | + pub package: String, | |
| 50 | + pub version: String, | |
| 51 | +} | |
| 52 | + | |
| 53 | +/// One alert, with the advisory's summary and severity for display. | |
| 54 | +#[derive(Debug, Clone, PartialEq, Eq)] | |
| 55 | +pub struct Alert { | |
| 56 | + pub key: AlertKey, | |
| 57 | + pub state: AlertState, | |
| 58 | + pub summary: String, | |
| 59 | + pub severity: String, | |
| 60 | + pub created_at: String, | |
| 61 | + pub updated_at: String, | |
| 62 | +} | |
| 63 | + | |
| 64 | +/// One admin's inbox row for a repo: how many alerts a scan opened since the row was last read. | |
| 65 | +#[derive(Debug, Clone, PartialEq, Eq)] | |
| 66 | +pub struct AdvisoryNotice { | |
| 67 | + pub repo_id: String, | |
| 68 | + /// The repo's owner and name, for the inbox row and its link. | |
| 69 | + pub repo_owner: String, | |
| 70 | + pub repo_name: String, | |
| 71 | + pub new_alerts: u64, | |
| 72 | + pub unread: bool, | |
| 73 | + pub updated_at: String, | |
| 74 | +} | |
| 75 | + | |
| 76 | +/// What a scan changed: alerts opened (new or reopened) and alerts marked fixed. | |
| 77 | +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] | |
| 78 | +pub struct ScanOutcome { | |
| 79 | + pub opened: usize, | |
| 80 | + pub fixed: usize, | |
| 81 | +} | |
| 82 | + | |
| 83 | +fn json_err(e: serde_json::Error) -> Error { | |
| 84 | + Error::Database(format!("advisory JSON: {e}")) | |
| 85 | +} | |
| 86 | + | |
| 87 | +fn map_advisory(row: &Row) -> Result<Advisory> { | |
| 88 | + let ranges_json = col_text(row, 5)?; | |
| 89 | + let versions_json = col_text(row, 6)?; | |
| 90 | + Ok(Advisory { | |
| 91 | + id: col_text(row, 0)?, | |
| 92 | + ecosystem: col_text(row, 1)?, | |
| 93 | + package: col_text(row, 2)?, | |
| 94 | + summary: col_text(row, 3)?, | |
| 95 | + severity: col_text(row, 4)?, | |
| 96 | + ranges: serde_json::from_str::<Vec<Range>>(&ranges_json).map_err(json_err)?, | |
| 97 | + versions: serde_json::from_str::<Vec<String>>(&versions_json).map_err(json_err)?, | |
| 98 | + modified: col_text(row, 7)?, | |
| 99 | + aliases: Vec::new(), | |
| 100 | + }) | |
| 101 | +} | |
| 102 | + | |
| 103 | +fn key_of(row: &Row, offset: usize) -> Result<AlertKey> { | |
| 104 | + Ok(AlertKey { | |
| 105 | + advisory_id: col_text(row, offset)?, | |
| 106 | + ecosystem: col_text(row, offset + 1)?, | |
| 107 | + package: col_text(row, offset + 2)?, | |
| 108 | + version: col_text(row, offset + 3)?, | |
| 109 | + }) | |
| 110 | +} | |
| 111 | + | |
| 112 | +fn map_alert(row: &Row) -> Result<Alert> { | |
| 113 | + Ok(Alert { | |
| 114 | + key: key_of(row, 0)?, | |
| 115 | + state: AlertState::parse(&col_text(row, 4)?)?, | |
| 116 | + summary: col_text(row, 5)?, | |
| 117 | + severity: col_text(row, 6)?, | |
| 118 | + created_at: col_text(row, 7)?, | |
| 119 | + updated_at: col_text(row, 8)?, | |
| 120 | + }) | |
| 121 | +} | |
| 122 | + | |
| 123 | +fn key_params(repo_id: &str, key: &AlertKey) -> Vec<Value> { | |
| 124 | + vec![ | |
| 125 | + Value::Text(repo_id.to_string()), | |
| 126 | + Value::Text(key.advisory_id.clone()), | |
| 127 | + Value::Text(key.ecosystem.clone()), | |
| 128 | + Value::Text(key.package.clone()), | |
| 129 | + Value::Text(key.version.clone()), | |
| 130 | + ] | |
| 131 | +} | |
| 132 | + | |
| 133 | +impl Store { | |
| 134 | + /// Replaces every advisory of `ecosystem` with `advisories`, in one transaction. Rows for other | |
| 135 | + /// ecosystems are not touched. Callers rescan the repos afterwards. | |
| 136 | + pub async fn replace_advisories(&self, ecosystem: &str, advisories: &[Advisory]) -> Result<()> { | |
| 137 | + let conn = self.inner.conn.lock().await; | |
| 138 | + let tx = conn.unchecked_transaction().await.map_err(db_err)?; | |
| 139 | + let outcome: Result<()> = async { | |
| 140 | + tx.execute( | |
| 141 | + "DELETE FROM advisories WHERE ecosystem = ?1", | |
| 142 | + (ecosystem.to_string(),), | |
| 143 | + ) | |
| 144 | + .await | |
| 145 | + .map_err(db_err)?; | |
| 146 | + for advisory in advisories { | |
| 147 | + if advisory.ecosystem != ecosystem { | |
| 148 | + continue; | |
| 149 | + } | |
| 150 | + let params = vec![ | |
| 151 | + Value::Text(advisory.id.clone()), | |
| 152 | + Value::Text(advisory.ecosystem.clone()), | |
| 153 | + Value::Text(advisory.package.clone()), | |
| 154 | + Value::Text(advisory.summary.clone()), | |
| 155 | + Value::Text(advisory.severity.clone()), | |
| 156 | + Value::Text(serde_json::to_string(&advisory.ranges).map_err(json_err)?), | |
| 157 | + Value::Text(serde_json::to_string(&advisory.versions).map_err(json_err)?), | |
| 158 | + Value::Text(advisory.modified.clone()), | |
| 159 | + ]; | |
| 160 | + tx.execute( | |
| 161 | + "INSERT OR REPLACE INTO advisories (id, ecosystem, package, summary, severity, \ | |
| 162 | + ranges_json, versions_json, modified) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", | |
| 163 | + params, | |
| 164 | + ) | |
| 165 | + .await | |
| 166 | + .map_err(db_err)?; | |
| 167 | + } | |
| 168 | + Ok(()) | |
| 169 | + } | |
| 170 | + .await; | |
| 171 | + finish(tx, outcome).await | |
| 172 | + } | |
| 173 | + | |
| 174 | + /// The advisories that name `package` in `ecosystem`. | |
| 175 | + pub async fn advisories_for_package( | |
| 176 | + &self, | |
| 177 | + ecosystem: &str, | |
| 178 | + package: &str, | |
| 179 | + ) -> Result<Vec<Advisory>> { | |
| 180 | + let conn = self.read_conn().await; | |
| 181 | + fetch_all( | |
| 182 | + &conn, | |
| 183 | + "SELECT id, ecosystem, package, summary, severity, ranges_json, versions_json, modified \ | |
| 184 | + FROM advisories WHERE ecosystem = ?1 AND package = ?2 ORDER BY id", | |
| 185 | + (ecosystem.to_string(), package.to_string()), | |
| 186 | + map_advisory, | |
| 187 | + ) | |
| 188 | + .await | |
| 189 | + } | |
| 190 | + | |
| 191 | + /// Every repo id, for a rescan after an advisory refresh. | |
| 192 | + pub async fn all_repo_ids(&self) -> Result<Vec<String>> { | |
| 193 | + let conn = self.read_conn().await; | |
| 194 | + fetch_all(&conn, "SELECT id FROM repos ORDER BY id", (), |row| { | |
| 195 | + col_text(row, 0) | |
| 196 | + }) | |
| 197 | + .await | |
| 198 | + } | |
| 199 | + | |
| 200 | + /// Records the dependencies of one repo and reconciles its alerts with the advisories that | |
| 201 | + /// match them. The dependency rows are replaced; the alerts are opened, reopened or marked | |
| 202 | + /// fixed as described in the module docs. | |
| 203 | + pub async fn scan_repo(&self, repo_id: &str, deps: &[Dependency]) -> Result<ScanOutcome> { | |
| 204 | + let unique: BTreeSet<&Dependency> = deps.iter().collect(); | |
| 205 | + // Matches are computed before the write lock, from the read pool. | |
| 206 | + let mut by_package: HashMap<(String, String), Vec<Advisory>> = HashMap::new(); | |
| 207 | + let mut matched: BTreeSet<AlertKey> = BTreeSet::new(); | |
| 208 | + for dep in &unique { | |
| 209 | + let pair = (dep.ecosystem.clone(), dep.package.clone()); | |
| 210 | + if !by_package.contains_key(&pair) { | |
| 211 | + let found = self | |
| 212 | + .advisories_for_package(&dep.ecosystem, &dep.package) | |
| 213 | + .await?; | |
| 214 | + by_package.insert(pair.clone(), found); | |
| 215 | + } | |
| 216 | + for advisory in by_package.get(&pair).into_iter().flatten() { | |
| 217 | + if affects(advisory, &dep.version) { | |
| 218 | + matched.insert(AlertKey { | |
| 219 | + advisory_id: advisory.id.clone(), | |
| 220 | + ecosystem: advisory.ecosystem.clone(), | |
| 221 | + package: advisory.package.clone(), | |
| 222 | + version: dep.version.clone(), | |
| 223 | + }); | |
| 224 | + } | |
| 225 | + } | |
| 226 | + } | |
| 227 | + | |
| 228 | + let conn = self.inner.conn.lock().await; | |
| 229 | + let tx = conn.unchecked_transaction().await.map_err(db_err)?; | |
| 230 | + let rid = repo_id.to_string(); | |
| 231 | + let outcome: Result<ScanOutcome> = async { | |
| 232 | + tx.execute( | |
| 233 | + "DELETE FROM repo_dependencies WHERE repo_id = ?1", | |
| 234 | + (rid.clone(),), | |
| 235 | + ) | |
| 236 | + .await | |
| 237 | + .map_err(db_err)?; | |
| 238 | + for dep in &unique { | |
| 239 | + tx.execute( | |
| 240 | + "INSERT OR IGNORE INTO repo_dependencies (repo_id, ecosystem, package, version, \ | |
| 241 | + manifest_path) VALUES (?1, ?2, ?3, ?4, ?5)", | |
| 242 | + ( | |
| 243 | + rid.clone(), | |
| 244 | + dep.ecosystem.clone(), | |
| 245 | + dep.package.clone(), | |
| 246 | + dep.version.clone(), | |
| 247 | + dep.manifest_path.clone(), | |
| 248 | + ), | |
| 249 | + ) | |
| 250 | + .await | |
| 251 | + .map_err(db_err)?; | |
| 252 | + } | |
| 253 | + | |
| 254 | + let existing: HashMap<AlertKey, AlertState> = fetch_all( | |
| 255 | + &tx, | |
| 256 | + "SELECT advisory_id, ecosystem, package, version, state FROM repo_alerts \ | |
| 257 | + WHERE repo_id = ?1", | |
| 258 | + (rid.clone(),), | |
| 259 | + |row| Ok((key_of(row, 0)?, AlertState::parse(&col_text(row, 4)?)?)), | |
| 260 | + ) | |
| 261 | + .await? | |
| 262 | + .into_iter() | |
| 263 | + .collect(); | |
| 264 | + | |
| 265 | + let now = now_stamp(); | |
| 266 | + let mut outcome = ScanOutcome::default(); | |
| 267 | + for key in &matched { | |
| 268 | + let mut params = key_params(&rid, key); | |
| 269 | + match existing.get(key) { | |
| 270 | + None => { | |
| 271 | + params.extend([ | |
| 272 | + Value::Text(AlertState::Open.as_str().to_string()), | |
| 273 | + Value::Text(now.clone()), | |
| 274 | + Value::Text(now.clone()), | |
| 275 | + ]); | |
| 276 | + tx.execute( | |
| 277 | + "INSERT INTO repo_alerts (repo_id, advisory_id, ecosystem, package, \ | |
| 278 | + version, state, created_at, updated_at) \ | |
| 279 | + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", | |
| 280 | + params, | |
| 281 | + ) | |
| 282 | + .await | |
| 283 | + .map_err(db_err)?; | |
| 284 | + outcome.opened += 1; | |
| 285 | + } | |
| 286 | + Some(AlertState::Fixed) => { | |
| 287 | + set_state(&tx, &rid, key, AlertState::Open, &now).await?; | |
| 288 | + outcome.opened += 1; | |
| 289 | + } | |
| 290 | + Some(AlertState::Open | AlertState::Dismissed) => {} | |
| 291 | + } | |
| 292 | + } | |
| 293 | + for (key, state) in &existing { | |
| 294 | + if *state == AlertState::Open && !matched.contains(key) { | |
| 295 | + set_state(&tx, &rid, key, AlertState::Fixed, &now).await?; | |
| 296 | + outcome.fixed += 1; | |
| 297 | + } | |
| 298 | + } | |
| 299 | + Ok(outcome) | |
| 300 | + } | |
| 301 | + .await; | |
| 302 | + finish(tx, outcome).await | |
| 303 | + } | |
| 304 | + | |
| 305 | + /// Moves one alert from `from` to `to`. Returns false when the alert is not in `from`. | |
| 306 | + pub async fn set_alert_state( | |
| 307 | + &self, | |
| 308 | + repo_id: &str, | |
| 309 | + key: &AlertKey, | |
| 310 | + from: AlertState, | |
| 311 | + to: AlertState, | |
| 312 | + ) -> Result<bool> { | |
| 313 | + let conn = self.inner.conn.lock().await; | |
| 314 | + let tx = conn.unchecked_transaction().await.map_err(db_err)?; | |
| 315 | + let rid = repo_id.to_string(); | |
| 316 | + let now = now_stamp(); | |
| 317 | + let outcome: Result<bool> = async { | |
| 318 | + let mut params = key_params(&rid, key); | |
| 319 | + params.extend([ | |
| 320 | + Value::Text(to.as_str().to_string()), | |
| 321 | + Value::Text(now.clone()), | |
| 322 | + Value::Text(from.as_str().to_string()), | |
| 323 | + ]); | |
| 324 | + let changed = tx | |
| 325 | + .execute( | |
| 326 | + "UPDATE repo_alerts SET state = ?6, updated_at = ?7 WHERE repo_id = ?1 AND \ | |
| 327 | + advisory_id = ?2 AND ecosystem = ?3 AND package = ?4 AND version = ?5 AND \ | |
| 328 | + state = ?8", | |
| 329 | + params, | |
| 330 | + ) | |
| 331 | + .await | |
| 332 | + .map_err(db_err)?; | |
| 333 | + Ok(changed > 0) | |
| 334 | + } | |
| 335 | + .await; | |
| 336 | + finish(tx, outcome).await | |
| 337 | + } | |
| 338 | + | |
| 339 | + /// The alerts of a repo, open first, with the advisory's summary and severity. | |
| 340 | + pub async fn list_alerts(&self, repo_id: &str) -> Result<Vec<Alert>> { | |
| 341 | + let conn = self.read_conn().await; | |
| 342 | + fetch_all( | |
| 343 | + &conn, | |
| 344 | + "SELECT a.advisory_id, a.ecosystem, a.package, a.version, a.state, \ | |
| 345 | + COALESCE(v.summary, ''), COALESCE(v.severity, ''), a.created_at, a.updated_at \ | |
| 346 | + FROM repo_alerts a \ | |
| 347 | + LEFT JOIN advisories v ON v.id = a.advisory_id AND v.ecosystem = a.ecosystem \ | |
| 348 | + AND v.package = a.package \ | |
| 349 | + WHERE a.repo_id = ?1 \ | |
| 350 | + ORDER BY CASE a.state WHEN 'open' THEN 0 WHEN 'dismissed' THEN 1 ELSE 2 END, \ | |
| 351 | + a.created_at, a.advisory_id, a.package", | |
| 352 | + (repo_id.to_string(),), | |
| 353 | + map_alert, | |
| 354 | + ) | |
| 355 | + .await | |
| 356 | + } | |
| 357 | + | |
| 358 | + /// How many dependency rows the last scan of a repo recorded. | |
| 359 | + pub async fn dependency_count(&self, repo_id: &str) -> Result<u64> { | |
| 360 | + let conn = self.read_conn().await; | |
| 361 | + let mut rows = conn | |
| 362 | + .query( | |
| 363 | + "SELECT COUNT(*) FROM repo_dependencies WHERE repo_id = ?1", | |
| 364 | + (repo_id.to_string(),), | |
| 365 | + ) | |
| 366 | + .await | |
| 367 | + .map_err(db_err)?; | |
| 368 | + let row = rows | |
| 369 | + .next() | |
| 370 | + .await | |
| 371 | + .map_err(db_err)? | |
| 372 | + .ok_or_else(super::missing_row)?; | |
| 373 | + u64::try_from(col_int(&row, 0)?).map_err(|_| Error::Database("negative count".into())) | |
| 374 | + } | |
| 375 | +} | |
| 376 | + | |
| 377 | +impl Store { | |
| 378 | + /// Adds `new_alerts` to the admin's inbox row for `repo_id` and marks it unread. While the row | |
| 379 | + /// is unread the counts add up; once read, a new count starts it again. | |
| 380 | + pub async fn notify_new_alerts( | |
| 381 | + &self, | |
| 382 | + user_id: &str, | |
| 383 | + repo_id: &str, | |
| 384 | + new_alerts: u64, | |
| 385 | + ) -> Result<()> { | |
| 386 | + let count = i64::try_from(new_alerts) | |
| 387 | + .map_err(|_| Error::Database("alert count too large".into()))?; | |
| 388 | + let conn = self.inner.conn.lock().await; | |
| 389 | + conn.execute( | |
| 390 | + "INSERT INTO advisory_notifications (user_id, repo_id, new_alerts, unread, updated_at) \ | |
| 391 | + VALUES (?1, ?2, ?3, 1, ?4) \ | |
| 392 | + ON CONFLICT (user_id, repo_id) DO UPDATE SET \ | |
| 393 | + new_alerts = CASE WHEN advisory_notifications.unread = 1 \ | |
| 394 | + THEN advisory_notifications.new_alerts + excluded.new_alerts \ | |
| 395 | + ELSE excluded.new_alerts END, \ | |
| 396 | + unread = 1, updated_at = excluded.updated_at", | |
| 397 | + vec![ | |
| 398 | + Value::Text(user_id.to_string()), | |
| 399 | + Value::Text(repo_id.to_string()), | |
| 400 | + Value::Integer(count), | |
| 401 | + Value::Text(now_stamp()), | |
| 402 | + ], | |
| 403 | + ) | |
| 404 | + .await | |
| 405 | + .map_err(db_err)?; | |
| 406 | + Ok(()) | |
| 407 | + } | |
| 408 | + | |
| 409 | + /// The user's inbox rows for repos with security alerts, newest first. `unread_only`, `before` | |
| 410 | + /// and `limit` mean what they mean in `list_notifications`. | |
| 411 | + pub async fn list_advisory_notifications( | |
| 412 | + &self, | |
| 413 | + user_id: &str, | |
| 414 | + unread_only: bool, | |
| 415 | + before: Option<&str>, | |
| 416 | + limit: u32, | |
| 417 | + ) -> Result<Vec<AdvisoryNotice>> { | |
| 418 | + let mut sql = format!("{ADVISORY_NOTICE_SELECT} WHERE n.user_id = ?"); | |
| 419 | + let mut params = vec![Value::Text(user_id.to_string())]; | |
| 420 | + if unread_only { | |
| 421 | + sql.push_str(" AND n.unread = 1"); | |
| 422 | + } | |
| 423 | + if let Some(stamp) = before { | |
| 424 | + sql.push_str(" AND n.updated_at < ?"); | |
| 425 | + params.push(Value::Text(stamp.to_string())); | |
| 426 | + } | |
| 427 | + sql.push_str(" ORDER BY n.updated_at DESC, n.repo_id DESC LIMIT ?"); | |
| 428 | + params.push(Value::Integer(i64::from(limit.clamp(1, 200)))); | |
| 429 | + let conn = self.read_conn().await; | |
| 430 | + fetch_all(&conn, &sql, params, map_advisory_notice).await | |
| 431 | + } | |
| 432 | + | |
| 433 | + /// The user's inbox row for one repo's alerts, if there is one. | |
| 434 | + pub async fn advisory_notification( | |
| 435 | + &self, | |
| 436 | + user_id: &str, | |
| 437 | + repo_id: &str, | |
| 438 | + ) -> Result<Option<AdvisoryNotice>> { | |
| 439 | + let conn = self.read_conn().await; | |
| 440 | + fetch_opt( | |
| 441 | + &conn, | |
| 442 | + &format!("{ADVISORY_NOTICE_SELECT} WHERE n.user_id = ?1 AND n.repo_id = ?2 LIMIT 1"), | |
| 443 | + (user_id.to_string(), repo_id.to_string()), | |
| 444 | + map_advisory_notice, | |
| 445 | + ) | |
| 446 | + .await | |
| 447 | + } | |
| 448 | + | |
| 449 | + /// Marks the user's inbox row for one repo's alerts read. Another user's row changes nothing. | |
| 450 | + pub async fn mark_advisory_notification_read( | |
| 451 | + &self, | |
| 452 | + user_id: &str, | |
| 453 | + repo_id: &str, | |
| 454 | + ) -> Result<()> { | |
| 455 | + let conn = self.inner.conn.lock().await; | |
| 456 | + conn.execute( | |
| 457 | + "UPDATE advisory_notifications SET unread = 0 WHERE user_id = ?1 AND repo_id = ?2", | |
| 458 | + (user_id.to_string(), repo_id.to_string()), | |
| 459 | + ) | |
| 460 | + .await | |
| 461 | + .map_err(db_err)?; | |
| 462 | + Ok(()) | |
| 463 | + } | |
| 464 | + | |
| 465 | + /// Marks the user's unread alert rows read, in one repo when `repo_id` is given, otherwise all. | |
| 466 | + pub async fn mark_all_advisory_notifications_read( | |
| 467 | + &self, | |
| 468 | + user_id: &str, | |
| 469 | + repo_id: Option<&str>, | |
| 470 | + ) -> Result<()> { | |
| 471 | + let conn = self.inner.conn.lock().await; | |
| 472 | + let result = match repo_id { | |
| 473 | + Some(repo) => { | |
| 474 | + conn.execute( | |
| 475 | + "UPDATE advisory_notifications SET unread = 0 WHERE user_id = ?1 AND repo_id = ?2 AND unread = 1", | |
| 476 | + (user_id.to_string(), repo.to_string()), | |
| 477 | + ) | |
| 478 | + .await | |
| 479 | + } | |
| 480 | + None => { | |
| 481 | + conn.execute( | |
| 482 | + "UPDATE advisory_notifications SET unread = 0 WHERE user_id = ?1 AND unread = 1", | |
| 483 | + (user_id.to_string(),), | |
| 484 | + ) | |
| 485 | + .await | |
| 486 | + } | |
| 487 | + }; | |
| 488 | + result.map_err(db_err)?; | |
| 489 | + Ok(()) | |
| 490 | + } | |
| 491 | +} | |
| 492 | + | |
| 493 | +/// Inbox rows of repos with alerts, joined with the repo and its owner. `?` placeholders are | |
| 494 | +/// appended by the callers. | |
| 495 | +const ADVISORY_NOTICE_SELECT: &str = "SELECT n.repo_id, o.username, r.name, n.new_alerts, \ | |
| 496 | + n.unread, n.updated_at FROM advisory_notifications n \ | |
| 497 | + JOIN repos r ON r.id = n.repo_id \ | |
| 498 | + JOIN users o ON o.id = r.owner_id"; | |
| 499 | + | |
| 500 | +fn map_advisory_notice(row: &Row) -> Result<AdvisoryNotice> { | |
| 501 | + Ok(AdvisoryNotice { | |
| 502 | + repo_id: col_text(row, 0)?, | |
| 503 | + repo_owner: col_text(row, 1)?, | |
| 504 | + repo_name: col_text(row, 2)?, | |
| 505 | + new_alerts: u64::try_from(col_int(row, 3)?) | |
| 506 | + .map_err(|_| Error::Database("negative count".into()))?, | |
| 507 | + unread: col_int(row, 4)? != 0, | |
| 508 | + updated_at: col_text(row, 5)?, | |
| 509 | + }) | |
| 510 | +} | |
| 511 | + | |
| 512 | +async fn set_state( | |
| 513 | + tx: &turso::transaction::Transaction<'_>, | |
| 514 | + repo_id: &str, | |
| 515 | + key: &AlertKey, | |
| 516 | + state: AlertState, | |
| 517 | + now: &str, | |
| 518 | +) -> Result<()> { | |
| 519 | + let mut params = key_params(repo_id, key); | |
| 520 | + params.extend([ | |
| 521 | + Value::Text(state.as_str().to_string()), | |
| 522 | + Value::Text(now.to_string()), | |
| 523 | + ]); | |
| 524 | + tx.execute( | |
| 525 | + "UPDATE repo_alerts SET state = ?6, updated_at = ?7 WHERE repo_id = ?1 AND advisory_id = ?2 \ | |
| 526 | + AND ecosystem = ?3 AND package = ?4 AND version = ?5", | |
| 527 | + params, | |
| 528 | + ) | |
| 529 | + .await | |
| 530 | + .map_err(db_err)?; | |
| 531 | + Ok(()) | |
| 532 | +} | |
| 533 | + | |
| 534 | +#[cfg(test)] | |
| 535 | +mod tests { | |
| 536 | + use super::*; | |
| 537 | + use crate::advisories::{Event, Range}; | |
| 538 | + use crate::store::Store; | |
| 539 | + | |
| 540 | + fn advisory(id: &str, package: &str, fixed: &str) -> Advisory { | |
| 541 | + Advisory { | |
| 542 | + id: id.into(), | |
| 543 | + ecosystem: "crates.io".into(), | |
| 544 | + package: package.into(), | |
| 545 | + summary: format!("{id} summary"), | |
| 546 | + severity: "HIGH".into(), | |
| 547 | + modified: String::new(), | |
| 548 | + aliases: Vec::new(), | |
| 549 | + ranges: vec![Range { | |
| 550 | + events: vec![Event::Introduced("0".into()), Event::Fixed(fixed.into())], | |
| 551 | + }], | |
| 552 | + versions: Vec::new(), | |
| 553 | + } | |
| 554 | + } | |
| 555 | + | |
| 556 | + fn dep(package: &str, version: &str) -> Dependency { | |
| 557 | + Dependency { | |
| 558 | + ecosystem: "crates.io".into(), | |
| 559 | + package: package.into(), | |
| 560 | + version: version.into(), | |
| 561 | + manifest_path: "Cargo.lock".into(), | |
| 562 | + } | |
| 563 | + } | |
| 564 | + | |
| 565 | + async fn store_with_repo(dir: &tempfile::TempDir) -> (Store, String) { | |
| 566 | + let store = Store::open(&dir.path().join("forge.db")).await.unwrap(); | |
| 567 | + let alice = store | |
| 568 | + .create_user("Alice", None, "hash", false) | |
| 569 | + .await | |
| 570 | + .unwrap(); | |
| 571 | + let repo = store | |
| 572 | + .create_repo(&alice.id, "demo", "", crate::Visibility::Private, "main") | |
| 573 | + .await | |
| 574 | + .unwrap(); | |
| 575 | + (store, repo.id) | |
| 576 | + } | |
| 577 | + | |
| 578 | + #[tokio::test] | |
| 579 | + async fn scan_opens_reconciles_and_keeps_dismissed() { | |
| 580 | + let dir = tempfile::tempdir().unwrap(); | |
| 581 | + let (store, repo) = store_with_repo(&dir).await; | |
| 582 | + store | |
| 583 | + .replace_advisories( | |
| 584 | + "crates.io", | |
| 585 | + &[ | |
| 586 | + advisory("RUSTSEC-1", "old", "2.0.0"), | |
| 587 | + advisory("RUSTSEC-2", "other", "2.0.0"), | |
| 588 | + ], | |
| 589 | + ) | |
| 590 | + .await | |
| 591 | + .unwrap(); | |
| 592 | + | |
| 593 | + let first = store | |
| 594 | + .scan_repo(&repo, &[dep("old", "1.0.0"), dep("other", "1.5.0")]) | |
| 595 | + .await | |
| 596 | + .unwrap(); | |
| 597 | + assert_eq!( | |
| 598 | + first, | |
| 599 | + ScanOutcome { | |
| 600 | + opened: 2, | |
| 601 | + fixed: 0 | |
| 602 | + } | |
| 603 | + ); | |
| 604 | + assert_eq!(store.dependency_count(&repo).await.unwrap(), 2); | |
| 605 | + | |
| 606 | + // Upgrading `other` past its fix closes its alert. `old` stays vulnerable. | |
| 607 | + let second = store | |
| 608 | + .scan_repo(&repo, &[dep("old", "1.0.0"), dep("other", "2.0.0")]) | |
| 609 | + .await | |
| 610 | + .unwrap(); | |
| 611 | + assert_eq!( | |
| 612 | + second, | |
| 613 | + ScanOutcome { | |
| 614 | + opened: 0, | |
| 615 | + fixed: 1 | |
| 616 | + } | |
| 617 | + ); | |
| 618 | + let third = store | |
| 619 | + .scan_repo(&repo, &[dep("old", "1.0.0")]) | |
| 620 | + .await | |
| 621 | + .unwrap(); | |
| 622 | + assert_eq!( | |
| 623 | + third, | |
| 624 | + ScanOutcome { | |
| 625 | + opened: 0, | |
| 626 | + fixed: 0 | |
| 627 | + } | |
| 628 | + ); | |
| 629 | + | |
| 630 | + let alerts = store.list_alerts(&repo).await.unwrap(); | |
| 631 | + assert_eq!(alerts.len(), 2); | |
| 632 | + let open = alerts | |
| 633 | + .iter() | |
| 634 | + .find(|a| a.key.advisory_id == "RUSTSEC-1") | |
| 635 | + .unwrap(); | |
| 636 | + assert_eq!(open.state, AlertState::Open); | |
| 637 | + assert_eq!(open.summary, "RUSTSEC-1 summary"); | |
| 638 | + assert_eq!(open.severity, "HIGH"); | |
| 639 | + let fixed = alerts | |
| 640 | + .iter() | |
| 641 | + .find(|a| a.key.advisory_id == "RUSTSEC-2") | |
| 642 | + .unwrap(); | |
| 643 | + assert_eq!(fixed.state, AlertState::Fixed); | |
| 644 | + | |
| 645 | + // Dismiss, then a scan keeps it dismissed; a reopen moves it back. | |
| 646 | + assert!( | |
| 647 | + store | |
| 648 | + .set_alert_state(&repo, &open.key, AlertState::Open, AlertState::Dismissed) | |
| 649 | + .await | |
| 650 | + .unwrap() | |
| 651 | + ); | |
| 652 | + assert!( | |
| 653 | + !store | |
| 654 | + .set_alert_state(&repo, &open.key, AlertState::Open, AlertState::Dismissed) | |
| 655 | + .await | |
| 656 | + .unwrap() | |
| 657 | + ); | |
| 658 | + let kept = store | |
| 659 | + .scan_repo(&repo, &[dep("old", "1.0.0")]) | |
| 660 | + .await | |
| 661 | + .unwrap(); | |
| 662 | + assert_eq!( | |
| 663 | + kept, | |
| 664 | + ScanOutcome { | |
| 665 | + opened: 0, | |
| 666 | + fixed: 0 | |
| 667 | + } | |
| 668 | + ); | |
| 669 | + assert!( | |
| 670 | + store | |
| 671 | + .set_alert_state(&repo, &open.key, AlertState::Dismissed, AlertState::Open) | |
| 672 | + .await | |
| 673 | + .unwrap() | |
| 674 | + ); | |
| 675 | + } | |
| 676 | + | |
| 677 | + #[tokio::test] | |
| 678 | + async fn fixed_alert_reopens_when_the_match_returns() { | |
| 679 | + let dir = tempfile::tempdir().unwrap(); | |
| 680 | + let (store, repo) = store_with_repo(&dir).await; | |
| 681 | + store | |
| 682 | + .replace_advisories("crates.io", &[advisory("RUSTSEC-3", "back", "2.0.0")]) | |
| 683 | + .await | |
| 684 | + .unwrap(); | |
| 685 | + store | |
| 686 | + .scan_repo(&repo, &[dep("back", "1.0.0")]) | |
| 687 | + .await | |
| 688 | + .unwrap(); | |
| 689 | + store | |
| 690 | + .scan_repo(&repo, &[dep("back", "2.0.0")]) | |
| 691 | + .await | |
| 692 | + .unwrap(); | |
| 693 | + let again = store | |
| 694 | + .scan_repo(&repo, &[dep("back", "1.0.1")]) | |
| 695 | + .await | |
| 696 | + .unwrap(); | |
| 697 | + assert_eq!( | |
| 698 | + again, | |
| 699 | + ScanOutcome { | |
| 700 | + opened: 1, | |
| 701 | + fixed: 0 | |
| 702 | + } | |
| 703 | + ); | |
| 704 | + let alerts = store.list_alerts(&repo).await.unwrap(); | |
| 705 | + assert_eq!(alerts.len(), 2); | |
| 706 | + assert_eq!(alerts[0].state, AlertState::Open); | |
| 707 | + } | |
| 708 | + | |
| 709 | + #[tokio::test] | |
| 710 | + async fn new_alert_notices_add_up_while_unread_and_stay_per_user() { | |
| 711 | + let dir = tempfile::tempdir().unwrap(); | |
| 712 | + let (store, repo) = store_with_repo(&dir).await; | |
| 713 | + let bob = store.create_user("Bob", None, "hash", false).await.unwrap(); | |
| 714 | + let owner = store.repo_by_id(&repo).await.unwrap().unwrap().owner_id; | |
| 715 | + store.notify_new_alerts(&owner, &repo, 2).await.unwrap(); | |
| 716 | + store.notify_new_alerts(&owner, &repo, 3).await.unwrap(); | |
| 717 | + let notices = store | |
| 718 | + .list_advisory_notifications(&owner, false, None, 50) | |
| 719 | + .await | |
| 720 | + .unwrap(); | |
| 721 | + assert_eq!(notices.len(), 1); | |
| 722 | + assert_eq!(notices[0].repo_id, repo); | |
| 723 | + assert!(notices[0].repo_owner.eq_ignore_ascii_case("alice")); | |
| 724 | + assert_eq!(notices[0].repo_name, "demo"); | |
| 725 | + assert_eq!(notices[0].new_alerts, 5); | |
| 726 | + assert!(notices[0].unread); | |
| 727 | + assert!( | |
| 728 | + store | |
| 729 | + .list_advisory_notifications(&bob.id, false, None, 50) | |
| 730 | + .await | |
| 731 | + .unwrap() | |
| 732 | + .is_empty() | |
| 733 | + ); | |
| 734 | + } | |
| 735 | + | |
| 736 | + #[tokio::test] | |
| 737 | + async fn alert_notices_are_marked_read_by_repo_or_all() { | |
| 738 | + let dir = tempfile::tempdir().unwrap(); | |
| 739 | + let (store, repo) = store_with_repo(&dir).await; | |
| 740 | + let owner = store.repo_by_id(&repo).await.unwrap().unwrap().owner_id; | |
| 741 | + store.notify_new_alerts(&owner, &repo, 1).await.unwrap(); | |
| 742 | + store | |
| 743 | + .mark_advisory_notification_read(&owner, &repo) | |
| 744 | + .await | |
| 745 | + .unwrap(); | |
| 746 | + let read = store.advisory_notification(&owner, &repo).await.unwrap(); | |
| 747 | + assert!(!read.expect("row").unread); | |
| 748 | + store.notify_new_alerts(&owner, &repo, 4).await.unwrap(); | |
| 749 | + store | |
| 750 | + .mark_all_advisory_notifications_read(&owner, Some(&repo)) | |
| 751 | + .await | |
| 752 | + .unwrap(); | |
| 753 | + let unread = store | |
| 754 | + .list_advisory_notifications(&owner, true, None, 50) | |
| 755 | + .await | |
| 756 | + .unwrap(); | |
| 757 | + assert!(unread.is_empty()); | |
| 758 | + } | |
| 759 | + | |
| 760 | + #[tokio::test] | |
| 761 | + async fn replacing_advisories_is_scoped_to_one_ecosystem() { | |
| 762 | + let dir = tempfile::tempdir().unwrap(); | |
| 763 | + let (store, _repo) = store_with_repo(&dir).await; | |
| 764 | + store | |
| 765 | + .replace_advisories("crates.io", &[advisory("RUSTSEC-4", "a", "2.0.0")]) | |
| 766 | + .await | |
| 767 | + .unwrap(); | |
| 768 | + store.replace_advisories("npm", &[]).await.unwrap(); | |
| 769 | + assert_eq!( | |
| 770 | + store | |
| 771 | + .advisories_for_package("crates.io", "a") | |
| 772 | + .await | |
| 773 | + .unwrap() | |
| 774 | + .len(), | |
| 775 | + 1 | |
| 776 | + ); | |
| 777 | + store | |
| 778 | + .replace_advisories("crates.io", &[advisory("RUSTSEC-5", "a", "3.0.0")]) | |
| 779 | + .await | |
| 780 | + .unwrap(); | |
| 781 | + let rows = store | |
| 782 | + .advisories_for_package("crates.io", "a") | |
| 783 | + .await | |
| 784 | + .unwrap(); | |
| 785 | + assert_eq!(rows.len(), 1); | |
| 786 | + assert_eq!(rows[0].id, "RUSTSEC-5"); | |
| 787 | + } | |
| 788 | +} |
crates/forge-core/src/store/audit.rs| @@ -0,0 +1,119 @@ | ||
| 1 | +//! Audit log storage. Append-only: the only delete is the retention purge. | |
| 2 | + | |
| 3 | +use turso::{Row, Value}; | |
| 4 | + | |
| 5 | +use super::{Store, col_opt_text, col_text, db_err, fetch_all}; | |
| 6 | +use crate::audit::{AuditAction, AuditCursor, AuditEntry, AuditFilter}; | |
| 7 | +use crate::error::{Error, Result}; | |
| 8 | + | |
| 9 | +const AUDIT_COLS: &str = "id, at, actor_id, actor_name, action, target_kind, target_id, \ | |
| 10 | + target_name, ip, data"; | |
| 11 | +/// Largest page `list_audit` returns, whatever the caller asks for. | |
| 12 | +const MAX_AUDIT_PAGE: u32 = 200; | |
| 13 | + | |
| 14 | +fn map_audit(row: &Row) -> Result<AuditEntry> { | |
| 15 | + let action = col_text(row, 4)?; | |
| 16 | + Ok(AuditEntry { | |
| 17 | + id: col_text(row, 0)?, | |
| 18 | + at: col_text(row, 1)?, | |
| 19 | + actor_id: col_opt_text(row, 2)?, | |
| 20 | + actor_name: col_text(row, 3)?, | |
| 21 | + action: AuditAction::parse(&action) | |
| 22 | + .ok_or_else(|| Error::Database(format!("unknown audit action {action:?}")))?, | |
| 23 | + target_kind: col_text(row, 5)?, | |
| 24 | + target_id: col_text(row, 6)?, | |
| 25 | + target_name: col_text(row, 7)?, | |
| 26 | + ip: col_text(row, 8)?, | |
| 27 | + data: col_text(row, 9)?, | |
| 28 | + }) | |
| 29 | +} | |
| 30 | + | |
| 31 | +impl Store { | |
| 32 | + /// Appends one entry. Nothing else in the store writes to `audit_log`. | |
| 33 | + pub async fn record_audit(&self, entry: &AuditEntry) -> Result<()> { | |
| 34 | + let actor_id = match &entry.actor_id { | |
| 35 | + Some(id) => Value::Text(id.clone()), | |
| 36 | + None => Value::Null, | |
| 37 | + }; | |
| 38 | + let params = vec![ | |
| 39 | + Value::Text(entry.id.clone()), | |
| 40 | + Value::Text(entry.at.clone()), | |
| 41 | + actor_id, | |
| 42 | + Value::Text(entry.actor_name.clone()), | |
| 43 | + Value::Text(entry.action.as_str().to_string()), | |
| 44 | + Value::Text(entry.target_kind.clone()), | |
| 45 | + Value::Text(entry.target_id.clone()), | |
| 46 | + Value::Text(entry.target_name.clone()), | |
| 47 | + Value::Text(entry.ip.clone()), | |
| 48 | + Value::Text(entry.data.clone()), | |
| 49 | + ]; | |
| 50 | + let conn = self.inner.conn.lock().await; | |
| 51 | + conn.execute( | |
| 52 | + "INSERT INTO audit_log (id, at, actor_id, actor_name, action, target_kind, target_id, \ | |
| 53 | + target_name, ip, data) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", | |
| 54 | + params, | |
| 55 | + ) | |
| 56 | + .await | |
| 57 | + .map_err(db_err)?; | |
| 58 | + Ok(()) | |
| 59 | + } | |
| 60 | + | |
| 61 | + /// Entries matching `filter`, newest first. `before` continues after the last entry of the | |
| 62 | + /// previous page. `limit` is clamped to 1..=200. | |
| 63 | + pub async fn list_audit( | |
| 64 | + &self, | |
| 65 | + filter: &AuditFilter, | |
| 66 | + before: Option<&AuditCursor>, | |
| 67 | + limit: u32, | |
| 68 | + ) -> Result<Vec<AuditEntry>> { | |
| 69 | + let mut clauses: Vec<&str> = Vec::new(); | |
| 70 | + let mut params: Vec<Value> = Vec::new(); | |
| 71 | + if let Some(actor) = &filter.actor_id { | |
| 72 | + clauses.push("actor_id = ?"); | |
| 73 | + params.push(Value::Text(actor.clone())); | |
| 74 | + } | |
| 75 | + if let Some(action) = filter.action { | |
| 76 | + clauses.push("action = ?"); | |
| 77 | + params.push(Value::Text(action.as_str().to_string())); | |
| 78 | + } | |
| 79 | + if let Some(kind) = &filter.target_kind { | |
| 80 | + clauses.push("target_kind = ?"); | |
| 81 | + params.push(Value::Text(kind.clone())); | |
| 82 | + } | |
| 83 | + if let Some(id) = &filter.target_id { | |
| 84 | + clauses.push("target_id = ?"); | |
| 85 | + params.push(Value::Text(id.clone())); | |
| 86 | + } | |
| 87 | + if let Some(user) = &filter.involving_user { | |
| 88 | + clauses.push("(actor_id = ? OR (target_kind = 'user' AND target_id = ?))"); | |
| 89 | + params.push(Value::Text(user.clone())); | |
| 90 | + params.push(Value::Text(user.clone())); | |
| 91 | + } | |
| 92 | + if let Some(cursor) = before { | |
| 93 | + clauses.push("(at < ? OR (at = ? AND id < ?))"); | |
| 94 | + params.push(Value::Text(cursor.at.clone())); | |
| 95 | + params.push(Value::Text(cursor.at.clone())); | |
| 96 | + params.push(Value::Text(cursor.id.clone())); | |
| 97 | + } | |
| 98 | + let where_sql = if clauses.is_empty() { | |
| 99 | + String::new() | |
| 100 | + } else { | |
| 101 | + format!("WHERE {}", clauses.join(" AND ")) | |
| 102 | + }; | |
| 103 | + let sql = format!( | |
| 104 | + "SELECT {AUDIT_COLS} FROM audit_log {where_sql} ORDER BY at DESC, id DESC LIMIT ?" | |
| 105 | + ); | |
| 106 | + params.push(Value::Integer(i64::from(limit.clamp(1, MAX_AUDIT_PAGE)))); | |
| 107 | + let conn = self.read_conn().await; | |
| 108 | + fetch_all(&conn, &sql, params, map_audit).await | |
| 109 | + } | |
| 110 | + | |
| 111 | + /// Retention: deletes entries stamped before `cutoff` (an RFC 3339 UTC stamp). Returns the | |
| 112 | + /// number of entries removed. This is the only delete on the audit log. | |
| 113 | + pub async fn purge_audit_before(&self, cutoff: &str) -> Result<u64> { | |
| 114 | + let conn = self.inner.conn.lock().await; | |
| 115 | + conn.execute("DELETE FROM audit_log WHERE at < ?1", (cutoff.to_string(),)) | |
| 116 | + .await | |
| 117 | + .map_err(db_err) | |
| 118 | + } | |
| 119 | +} |
crates/forge-core/src/store/backup_runs.rs| @@ -0,0 +1,349 @@ | ||
| 1 | +//! Records of backup and fsck runs, shown on `/admin/backups`. The scheduler and the admin | |
| 2 | +//! actions write them; nothing deletes them. | |
| 3 | + | |
| 4 | +use turso::{Row, Value}; | |
| 5 | + | |
| 6 | +use super::{Store, col_text, db_err, fetch_all, new_id}; | |
| 7 | +use crate::error::{Error, Result}; | |
| 8 | +use crate::time::now_stamp; | |
| 9 | + | |
| 10 | +const RUN_COLS: &str = "id, kind, started_at, finished_at, ok, path, bytes, error, \"trigger\""; | |
| 11 | +/// Largest page `recent_runs` returns. | |
| 12 | +const MAX_RUNS: u32 = 200; | |
| 13 | + | |
| 14 | +/// What a run did. | |
| 15 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 16 | +pub enum RunKind { | |
| 17 | + Backup, | |
| 18 | + Fsck, | |
| 19 | +} | |
| 20 | + | |
| 21 | +impl RunKind { | |
| 22 | + pub fn as_str(self) -> &'static str { | |
| 23 | + match self { | |
| 24 | + RunKind::Backup => "backup", | |
| 25 | + RunKind::Fsck => "fsck", | |
| 26 | + } | |
| 27 | + } | |
| 28 | + | |
| 29 | + pub fn parse(text: &str) -> Option<RunKind> { | |
| 30 | + match text { | |
| 31 | + "backup" => Some(RunKind::Backup), | |
| 32 | + "fsck" => Some(RunKind::Fsck), | |
| 33 | + _ => None, | |
| 34 | + } | |
| 35 | + } | |
| 36 | +} | |
| 37 | + | |
| 38 | +/// What started a run. | |
| 39 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 40 | +pub enum RunTrigger { | |
| 41 | + Schedule, | |
| 42 | + Manual, | |
| 43 | + Cli, | |
| 44 | +} | |
| 45 | + | |
| 46 | +impl RunTrigger { | |
| 47 | + pub fn as_str(self) -> &'static str { | |
| 48 | + match self { | |
| 49 | + RunTrigger::Schedule => "schedule", | |
| 50 | + RunTrigger::Manual => "manual", | |
| 51 | + RunTrigger::Cli => "cli", | |
| 52 | + } | |
| 53 | + } | |
| 54 | + | |
| 55 | + pub fn parse(text: &str) -> Option<RunTrigger> { | |
| 56 | + match text { | |
| 57 | + "schedule" => Some(RunTrigger::Schedule), | |
| 58 | + "manual" => Some(RunTrigger::Manual), | |
| 59 | + "cli" => Some(RunTrigger::Cli), | |
| 60 | + _ => None, | |
| 61 | + } | |
| 62 | + } | |
| 63 | +} | |
| 64 | + | |
| 65 | +/// One row of `backup_runs`. `finished_at` and `ok` are `None` while the run is in progress. | |
| 66 | +#[derive(Debug, Clone, PartialEq, Eq)] | |
| 67 | +pub struct BackupRun { | |
| 68 | + pub id: String, | |
| 69 | + pub kind: RunKind, | |
| 70 | + pub trigger: RunTrigger, | |
| 71 | + pub started_at: String, | |
| 72 | + pub finished_at: Option<String>, | |
| 73 | + pub ok: Option<bool>, | |
| 74 | + pub path: Option<String>, | |
| 75 | + pub bytes: Option<u64>, | |
| 76 | + pub error: Option<String>, | |
| 77 | +} | |
| 78 | + | |
| 79 | +fn opt_int(row: &Row, i: usize) -> Result<Option<i64>> { | |
| 80 | + match row.get_value(i).map_err(db_err)? { | |
| 81 | + Value::Integer(n) => Ok(Some(n)), | |
| 82 | + Value::Null => Ok(None), | |
| 83 | + _ => Err(Error::Database(format!("column {i} is not an integer"))), | |
| 84 | + } | |
| 85 | +} | |
| 86 | + | |
| 87 | +fn opt_text(row: &Row, i: usize) -> Result<Option<String>> { | |
| 88 | + match row.get_value(i).map_err(db_err)? { | |
| 89 | + Value::Text(s) => Ok(Some(s)), | |
| 90 | + Value::Null => Ok(None), | |
| 91 | + _ => Err(Error::Database(format!("column {i} is not text"))), | |
| 92 | + } | |
| 93 | +} | |
| 94 | + | |
| 95 | +fn map_run(row: &Row) -> Result<BackupRun> { | |
| 96 | + let kind = col_text(row, 1)?; | |
| 97 | + let trigger = col_text(row, 8)?; | |
| 98 | + let bytes = match opt_int(row, 6)? { | |
| 99 | + Some(n) => Some(u64::try_from(n).map_err(|_| Error::Database("negative run size".into()))?), | |
| 100 | + None => None, | |
| 101 | + }; | |
| 102 | + Ok(BackupRun { | |
| 103 | + id: col_text(row, 0)?, | |
| 104 | + kind: RunKind::parse(&kind) | |
| 105 | + .ok_or_else(|| Error::Database(format!("unknown run kind {kind:?}")))?, | |
| 106 | + trigger: RunTrigger::parse(&trigger) | |
| 107 | + .ok_or_else(|| Error::Database(format!("unknown run trigger {trigger:?}")))?, | |
| 108 | + started_at: col_text(row, 2)?, | |
| 109 | + finished_at: opt_text(row, 3)?, | |
| 110 | + ok: opt_int(row, 4)?.map(|n| n != 0), | |
| 111 | + path: opt_text(row, 5)?, | |
| 112 | + bytes, | |
| 113 | + error: opt_text(row, 7)?, | |
| 114 | + }) | |
| 115 | +} | |
| 116 | + | |
| 117 | +impl Store { | |
| 118 | + /// Records the start of a run and returns its id. The row stays unfinished until | |
| 119 | + /// [`Store::finish_run`] is called. | |
| 120 | + pub async fn start_run(&self, kind: RunKind, trigger: RunTrigger) -> Result<String> { | |
| 121 | + let id = new_id(); | |
| 122 | + let conn = self.inner.conn.lock().await; | |
| 123 | + conn.execute( | |
| 124 | + "INSERT INTO backup_runs (id, kind, started_at, finished_at, ok, path, bytes, error, \"trigger\") \ | |
| 125 | + VALUES (?1, ?2, ?3, NULL, NULL, NULL, NULL, NULL, ?4)", | |
| 126 | + vec![ | |
| 127 | + Value::Text(id.clone()), | |
| 128 | + Value::Text(kind.as_str().to_owned()), | |
| 129 | + Value::Text(now_stamp()), | |
| 130 | + Value::Text(trigger.as_str().to_owned()), | |
| 131 | + ], | |
| 132 | + ) | |
| 133 | + .await | |
| 134 | + .map_err(db_err)?; | |
| 135 | + Ok(id) | |
| 136 | + } | |
| 137 | + | |
| 138 | + /// Marks run `id` finished. `path` and `bytes` describe a finished backup set; `error` holds | |
| 139 | + /// the first failure when `ok` is false. | |
| 140 | + pub async fn finish_run( | |
| 141 | + &self, | |
| 142 | + id: &str, | |
| 143 | + ok: bool, | |
| 144 | + path: Option<&str>, | |
| 145 | + bytes: Option<u64>, | |
| 146 | + error: Option<&str>, | |
| 147 | + ) -> Result<()> { | |
| 148 | + let bytes = match bytes { | |
| 149 | + Some(n) => Value::Integer( | |
| 150 | + i64::try_from(n).map_err(|_| Error::Invalid("run size is too large".into()))?, | |
| 151 | + ), | |
| 152 | + None => Value::Null, | |
| 153 | + }; | |
| 154 | + let conn = self.inner.conn.lock().await; | |
| 155 | + let changed = conn | |
| 156 | + .execute( | |
| 157 | + "UPDATE backup_runs SET finished_at = ?2, ok = ?3, path = ?4, bytes = ?5, error = ?6 \ | |
| 158 | + WHERE id = ?1", | |
| 159 | + vec![ | |
| 160 | + Value::Text(id.to_owned()), | |
| 161 | + Value::Text(now_stamp()), | |
| 162 | + Value::Integer(i64::from(ok)), | |
| 163 | + path.map_or(Value::Null, |p| Value::Text(p.to_owned())), | |
| 164 | + bytes, | |
| 165 | + error.map_or(Value::Null, |e| Value::Text(e.to_owned())), | |
| 166 | + ], | |
| 167 | + ) | |
| 168 | + .await | |
| 169 | + .map_err(db_err)?; | |
| 170 | + if changed == 0 { | |
| 171 | + return Err(Error::NotFound); | |
| 172 | + } | |
| 173 | + Ok(()) | |
| 174 | + } | |
| 175 | + | |
| 176 | + /// The newest `limit` runs of any kind, newest first. `limit` is clamped to 1..=200. | |
| 177 | + pub async fn recent_runs(&self, limit: u32) -> Result<Vec<BackupRun>> { | |
| 178 | + let sql = format!( | |
| 179 | + "SELECT {RUN_COLS} FROM backup_runs ORDER BY started_at DESC, id DESC LIMIT ?1" | |
| 180 | + ); | |
| 181 | + let conn = self.read_conn().await; | |
| 182 | + fetch_all(&conn, &sql, (i64::from(limit.clamp(1, MAX_RUNS)),), map_run).await | |
| 183 | + } | |
| 184 | + | |
| 185 | + /// The newest run of `kind` that finished with `ok`, if any. | |
| 186 | + pub async fn last_ok(&self, kind: RunKind) -> Result<Option<BackupRun>> { | |
| 187 | + self.latest_run(kind, true).await | |
| 188 | + } | |
| 189 | + | |
| 190 | + /// The newest run of `kind`, whatever its outcome. | |
| 191 | + pub async fn last_run(&self, kind: RunKind) -> Result<Option<BackupRun>> { | |
| 192 | + self.latest_run(kind, false).await | |
| 193 | + } | |
| 194 | + | |
| 195 | + async fn latest_run(&self, kind: RunKind, only_ok: bool) -> Result<Option<BackupRun>> { | |
| 196 | + let filter = if only_ok { " AND ok = 1" } else { "" }; | |
| 197 | + let sql = format!( | |
| 198 | + "SELECT {RUN_COLS} FROM backup_runs WHERE kind = ?1{filter} \ | |
| 199 | + ORDER BY started_at DESC, id DESC LIMIT 1" | |
| 200 | + ); | |
| 201 | + let conn = self.read_conn().await; | |
| 202 | + let rows = fetch_all(&conn, &sql, (kind.as_str().to_owned(),), map_run).await?; | |
| 203 | + Ok(rows.into_iter().next()) | |
| 204 | + } | |
| 205 | + | |
| 206 | + /// Ids of every repo, in id order. The fsck job walks this list. | |
| 207 | + pub async fn repo_ids(&self) -> Result<Vec<String>> { | |
| 208 | + let conn = self.read_conn().await; | |
| 209 | + fetch_all(&conn, "SELECT id FROM repos ORDER BY id", (), |row| { | |
| 210 | + col_text(row, 0) | |
| 211 | + }) | |
| 212 | + .await | |
| 213 | + } | |
| 214 | +} | |
| 215 | + | |
| 216 | +#[cfg(test)] | |
| 217 | +mod tests { | |
| 218 | + use super::*; | |
| 219 | + | |
| 220 | + async fn store() -> (tempfile::TempDir, Store) { | |
| 221 | + let dir = tempfile::tempdir().expect("tempdir"); | |
| 222 | + let store = Store::open(&dir.path().join("forge.db")) | |
| 223 | + .await | |
| 224 | + .expect("open"); | |
| 225 | + (dir, store) | |
| 226 | + } | |
| 227 | + | |
| 228 | + #[tokio::test] | |
| 229 | + async fn start_and_finish_round_trip() { | |
| 230 | + let (_dir, store) = store().await; | |
| 231 | + let id = store | |
| 232 | + .start_run(RunKind::Backup, RunTrigger::Manual) | |
| 233 | + .await | |
| 234 | + .expect("start"); | |
| 235 | + let open = store.recent_runs(20).await.expect("recent"); | |
| 236 | + assert_eq!(open.len(), 1); | |
| 237 | + assert_eq!(open[0].id, id); | |
| 238 | + assert_eq!(open[0].ok, None, "an unfinished run has no outcome"); | |
| 239 | + assert_eq!(open[0].finished_at, None); | |
| 240 | + | |
| 241 | + store | |
| 242 | + .finish_run(&id, true, Some("/backups/forge-backup-x"), Some(4096), None) | |
| 243 | + .await | |
| 244 | + .expect("finish"); | |
| 245 | + let done = store.recent_runs(20).await.expect("recent"); | |
| 246 | + assert_eq!(done[0].ok, Some(true)); | |
| 247 | + assert_eq!(done[0].path.as_deref(), Some("/backups/forge-backup-x")); | |
| 248 | + assert_eq!(done[0].bytes, Some(4096)); | |
| 249 | + assert!(done[0].finished_at.is_some()); | |
| 250 | + assert_eq!(done[0].trigger, RunTrigger::Manual); | |
| 251 | + | |
| 252 | + assert!(matches!( | |
| 253 | + store.finish_run("nope", true, None, None, None).await, | |
| 254 | + Err(Error::NotFound) | |
| 255 | + )); | |
| 256 | + } | |
| 257 | + | |
| 258 | + #[tokio::test] | |
| 259 | + async fn last_ok_skips_failures_and_kinds() { | |
| 260 | + let (_dir, store) = store().await; | |
| 261 | + assert!(store.last_ok(RunKind::Backup).await.expect("q").is_none()); | |
| 262 | + | |
| 263 | + let good = store | |
| 264 | + .start_run(RunKind::Backup, RunTrigger::Schedule) | |
| 265 | + .await | |
| 266 | + .expect("start"); | |
| 267 | + store | |
| 268 | + .finish_run(&good, true, None, None, None) | |
| 269 | + .await | |
| 270 | + .expect("finish"); | |
| 271 | + let bad = store | |
| 272 | + .start_run(RunKind::Backup, RunTrigger::Schedule) | |
| 273 | + .await | |
| 274 | + .expect("start"); | |
| 275 | + store | |
| 276 | + .finish_run(&bad, false, None, None, Some("disk full")) | |
| 277 | + .await | |
| 278 | + .expect("finish"); | |
| 279 | + let fsck = store | |
| 280 | + .start_run(RunKind::Fsck, RunTrigger::Cli) | |
| 281 | + .await | |
| 282 | + .expect("start"); | |
| 283 | + store | |
| 284 | + .finish_run(&fsck, true, None, None, None) | |
| 285 | + .await | |
| 286 | + .expect("finish"); | |
| 287 | + | |
| 288 | + let ok = store | |
| 289 | + .last_ok(RunKind::Backup) | |
| 290 | + .await | |
| 291 | + .expect("q") | |
| 292 | + .expect("some"); | |
| 293 | + assert_eq!(ok.id, good); | |
| 294 | + let last = store | |
| 295 | + .last_run(RunKind::Backup) | |
| 296 | + .await | |
| 297 | + .expect("q") | |
| 298 | + .expect("some"); | |
| 299 | + assert_eq!(last.id, bad); | |
| 300 | + assert_eq!(last.error.as_deref(), Some("disk full")); | |
| 301 | + let fsck_ok = store | |
| 302 | + .last_ok(RunKind::Fsck) | |
| 303 | + .await | |
| 304 | + .expect("q") | |
| 305 | + .expect("some"); | |
| 306 | + assert_eq!(fsck_ok.kind, RunKind::Fsck); | |
| 307 | + assert_eq!(fsck_ok.trigger, RunTrigger::Cli); | |
| 308 | + } | |
| 309 | + | |
| 310 | + #[tokio::test] | |
| 311 | + async fn recent_runs_is_newest_first_and_limited() { | |
| 312 | + let (_dir, store) = store().await; | |
| 313 | + let mut ids = Vec::new(); | |
| 314 | + for _ in 0..3 { | |
| 315 | + ids.push( | |
| 316 | + store | |
| 317 | + .start_run(RunKind::Backup, RunTrigger::Manual) | |
| 318 | + .await | |
| 319 | + .expect("start"), | |
| 320 | + ); | |
| 321 | + } | |
| 322 | + let runs = store.recent_runs(2).await.expect("recent"); | |
| 323 | + assert_eq!(runs.len(), 2); | |
| 324 | + assert_eq!(runs[0].id, ids[2]); | |
| 325 | + assert_eq!(runs[1].id, ids[1]); | |
| 326 | + assert_eq!(store.recent_runs(0).await.expect("recent").len(), 1); | |
| 327 | + } | |
| 328 | + | |
| 329 | + #[tokio::test] | |
| 330 | + async fn repo_ids_lists_every_repo() { | |
| 331 | + let (_dir, store) = store().await; | |
| 332 | + assert!(store.repo_ids().await.expect("ids").is_empty()); | |
| 333 | + let user = store | |
| 334 | + .create_user("alice", None, "hash", false) | |
| 335 | + .await | |
| 336 | + .expect("user"); | |
| 337 | + let repo = store | |
| 338 | + .create_repo( | |
| 339 | + &user.id, | |
| 340 | + "demo", | |
| 341 | + "", | |
| 342 | + crate::model::Visibility::Private, | |
| 343 | + "main", | |
| 344 | + ) | |
| 345 | + .await | |
| 346 | + .expect("repo"); | |
| 347 | + assert_eq!(store.repo_ids().await.expect("ids"), vec![repo.id]); | |
| 348 | + } | |
| 349 | +} |
crates/forge-core/src/store/ci.rsDiff too large; view file
| @@ -0,0 +1,2013 @@ | ||
| 1 | +//! CI store: runners, registration tokens, runs, jobs, artifacts and sealed repo secrets. | |
| 2 | +//! | |
| 3 | +//! Pure SELECTs run on `read_conn()`. Writes, and anything that reads then writes, run on the | |
| 4 | +//! writer. Multi-step changes (create, claim, finish, cancel, reap, purge) run in one transaction. | |
| 5 | +//! The `needs` and `runs_on` arrays are read out of the JobSpec JSON in `spec_json`. | |
| 6 | + | |
| 7 | +use std::collections::BTreeMap; | |
| 8 | +use std::time::Duration; | |
| 9 | + | |
| 10 | +use turso::{Connection, Row, Value}; | |
| 11 | + | |
| 12 | +use super::{ | |
| 13 | + Store, col_blob, col_bool, col_int, col_opt_text, col_text, db_err, fetch_all, fetch_opt, | |
| 14 | + finish, insert_err, missing_row, new_id, | |
| 15 | +}; | |
| 16 | +use crate::ci::{CiArtifact, CiJob, CiRun, CiRunner, JobState, NewJob, RunState}; | |
| 17 | +use crate::error::{Error, Result}; | |
| 18 | +use crate::secret; | |
| 19 | +use crate::time::{now_stamp, stamp_after}; | |
| 20 | +use crate::token::{generate_token, hash_token}; | |
| 21 | + | |
| 22 | +const JOB_COLS: &str = "id, run_id, name, spec, state, runner_id, exit_code, message, queued_at, \ | |
| 23 | + started_at, finished_at, heartbeat_at, cancel_requested, log_bytes"; | |
| 24 | +const RUN_COLS: &str = "id, repo_id, number, trigger, git_ref, commit_oid, pull_number, actor_id, \ | |
| 25 | + state, config_error, created_at, finished_at"; | |
| 26 | +const RUNNER_COLS: &str = "id, name, labels, created_at, last_seen_at, disabled"; | |
| 27 | +const ARTIFACT_COLS: &str = "id, job_id, name, size, sha256, created_at"; | |
| 28 | + | |
| 29 | +/// Longest runner name, in bytes. | |
| 30 | +const MAX_RUNNER_NAME: usize = 100; | |
| 31 | +/// Longest runner label, in bytes. | |
| 32 | +const MAX_LABEL: usize = 64; | |
| 33 | +/// Longest page `list_runs` returns. | |
| 34 | +const MAX_PER_PAGE: u32 = 100; | |
| 35 | + | |
| 36 | +fn text(s: &str) -> Value { | |
| 37 | + Value::Text(s.to_string()) | |
| 38 | +} | |
| 39 | + | |
| 40 | +fn opt_text(v: Option<&str>) -> Value { | |
| 41 | + v.map_or(Value::Null, text) | |
| 42 | +} | |
| 43 | + | |
| 44 | +fn opt_int_value(v: Option<i64>) -> Value { | |
| 45 | + v.map_or(Value::Null, Value::Integer) | |
| 46 | +} | |
| 47 | + | |
| 48 | +/// An integer column that may be NULL. | |
| 49 | +fn opt_int(row: &Row, i: usize) -> Result<Option<i64>> { | |
| 50 | + match row.get_value(i).map_err(db_err)? { | |
| 51 | + Value::Integer(n) => Ok(Some(n)), | |
| 52 | + Value::Null => Ok(None), | |
| 53 | + _ => Err(Error::Database(format!("column {i} is not an integer"))), | |
| 54 | + } | |
| 55 | +} | |
| 56 | + | |
| 57 | +fn map_runner(row: &Row) -> Result<CiRunner> { | |
| 58 | + let labels = serde_json::from_str::<Vec<String>>(&col_text(row, 2)?) | |
| 59 | + .map_err(|e| Error::Database(format!("runner labels are not a string list: {e}")))?; | |
| 60 | + Ok(CiRunner { | |
| 61 | + id: col_text(row, 0)?, | |
| 62 | + name: col_text(row, 1)?, | |
| 63 | + labels, | |
| 64 | + created_at: col_text(row, 3)?, | |
| 65 | + last_seen_at: col_opt_text(row, 4)?, | |
| 66 | + disabled: col_bool(row, 5)?, | |
| 67 | + }) | |
| 68 | +} | |
| 69 | + | |
| 70 | +fn map_run(row: &Row) -> Result<CiRun> { | |
| 71 | + Ok(CiRun { | |
| 72 | + id: col_text(row, 0)?, | |
| 73 | + repo_id: col_text(row, 1)?, | |
| 74 | + number: col_int(row, 2)?, | |
| 75 | + trigger: col_text(row, 3)?, | |
| 76 | + git_ref: col_text(row, 4)?, | |
| 77 | + commit_oid: col_text(row, 5)?, | |
| 78 | + pull_number: opt_int(row, 6)?, | |
| 79 | + actor_id: col_text(row, 7)?, | |
| 80 | + state: col_text(row, 8)?.parse::<RunState>()?, | |
| 81 | + config_error: col_opt_text(row, 9)?, | |
| 82 | + created_at: col_text(row, 10)?, | |
| 83 | + finished_at: col_opt_text(row, 11)?, | |
| 84 | + }) | |
| 85 | +} | |
| 86 | + | |
| 87 | +fn map_job(row: &Row) -> Result<CiJob> { | |
| 88 | + Ok(CiJob { | |
| 89 | + id: col_text(row, 0)?, | |
| 90 | + run_id: col_text(row, 1)?, | |
| 91 | + name: col_text(row, 2)?, | |
| 92 | + spec_json: col_text(row, 3)?, | |
| 93 | + state: col_text(row, 4)?.parse::<JobState>()?, | |
| 94 | + runner_id: col_opt_text(row, 5)?, | |
| 95 | + exit_code: opt_int(row, 6)?, | |
| 96 | + message: col_opt_text(row, 7)?, | |
| 97 | + queued_at: col_opt_text(row, 8)?, | |
| 98 | + started_at: col_opt_text(row, 9)?, | |
| 99 | + finished_at: col_opt_text(row, 10)?, | |
| 100 | + heartbeat_at: col_opt_text(row, 11)?, | |
| 101 | + cancel_requested: col_bool(row, 12)?, | |
| 102 | + log_bytes: col_int(row, 13)?, | |
| 103 | + }) | |
| 104 | +} | |
| 105 | + | |
| 106 | +fn map_artifact(row: &Row) -> Result<CiArtifact> { | |
| 107 | + Ok(CiArtifact { | |
| 108 | + id: col_text(row, 0)?, | |
| 109 | + job_id: col_text(row, 1)?, | |
| 110 | + name: col_text(row, 2)?, | |
| 111 | + size: col_int(row, 3)?, | |
| 112 | + sha256: col_text(row, 4)?, | |
| 113 | + created_at: col_text(row, 5)?, | |
| 114 | + }) | |
| 115 | +} | |
| 116 | + | |
| 117 | +/// The `needs` array of a JobSpec. Missing or malformed input gives no needs. | |
| 118 | +fn spec_needs(spec: &str) -> Vec<String> { | |
| 119 | + serde_json::from_str::<serde_json::Value>(spec) | |
| 120 | + .ok() | |
| 121 | + .and_then(|v| { | |
| 122 | + v.get("needs") | |
| 123 | + .and_then(serde_json::Value::as_array) | |
| 124 | + .map(|items| { | |
| 125 | + items | |
| 126 | + .iter() | |
| 127 | + .filter_map(|n| n.as_str().map(str::to_owned)) | |
| 128 | + .collect() | |
| 129 | + }) | |
| 130 | + }) | |
| 131 | + .unwrap_or_default() | |
| 132 | +} | |
| 133 | + | |
| 134 | +/// True when every label in the spec's `runs_on` is one of the runner's labels. A missing | |
| 135 | +/// `runs_on` means no requirement. A malformed spec is never claimable. | |
| 136 | +fn runs_on_covered(spec: &str, labels: &[String]) -> bool { | |
| 137 | + let Ok(value) = serde_json::from_str::<serde_json::Value>(spec) else { | |
| 138 | + return false; | |
| 139 | + }; | |
| 140 | + match value.get("runs_on") { | |
| 141 | + None | Some(serde_json::Value::Null) => true, | |
| 142 | + Some(serde_json::Value::Array(items)) => items.iter().all(|item| { | |
| 143 | + item.as_str() | |
| 144 | + .is_some_and(|want| labels.iter().any(|have| have == want)) | |
| 145 | + }), | |
| 146 | + Some(_) => false, | |
| 147 | + } | |
| 148 | +} | |
| 149 | + | |
| 150 | +/// Secret names follow the JobSpec rule: `^[A-Z_][A-Z0-9_]{0,63}$`. | |
| 151 | +fn validate_secret_name(name: &str) -> Result<()> { | |
| 152 | + let first_ok = name | |
| 153 | + .bytes() | |
| 154 | + .next() | |
| 155 | + .is_some_and(|b| b.is_ascii_uppercase() || b == b'_'); | |
| 156 | + let rest_ok = name | |
| 157 | + .bytes() | |
| 158 | + .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_'); | |
| 159 | + if first_ok && rest_ok && name.len() <= 64 { | |
| 160 | + Ok(()) | |
| 161 | + } else { | |
| 162 | + Err(Error::Invalid(format!( | |
| 163 | + "secret name {name:?} must match [A-Z_][A-Z0-9_]{{0,63}}" | |
| 164 | + ))) | |
| 165 | + } | |
| 166 | +} | |
| 167 | + | |
| 168 | +async fn load_job(conn: &Connection, id: &str) -> Result<Option<CiJob>> { | |
| 169 | + fetch_opt( | |
| 170 | + conn, | |
| 171 | + &format!("SELECT {JOB_COLS} FROM ci_jobs WHERE id = ?1"), | |
| 172 | + (id.to_string(),), | |
| 173 | + map_job, | |
| 174 | + ) | |
| 175 | + .await | |
| 176 | +} | |
| 177 | + | |
| 178 | +async fn jobs_of_run(conn: &Connection, run_id: &str) -> Result<Vec<CiJob>> { | |
| 179 | + fetch_all( | |
| 180 | + conn, | |
| 181 | + &format!("SELECT {JOB_COLS} FROM ci_jobs WHERE run_id = ?1 ORDER BY name"), | |
| 182 | + (run_id.to_string(),), | |
| 183 | + map_job, | |
| 184 | + ) | |
| 185 | + .await | |
| 186 | +} | |
| 187 | + | |
| 188 | +async fn load_run(conn: &Connection, id: &str) -> Result<Option<CiRun>> { | |
| 189 | + fetch_opt( | |
| 190 | + conn, | |
| 191 | + &format!("SELECT {RUN_COLS} FROM ci_runs WHERE id = ?1"), | |
| 192 | + (id.to_string(),), | |
| 193 | + map_run, | |
| 194 | + ) | |
| 195 | + .await | |
| 196 | +} | |
| 197 | + | |
| 198 | +async fn load_runner(conn: &Connection, id: &str) -> Result<Option<CiRunner>> { | |
| 199 | + fetch_opt( | |
| 200 | + conn, | |
| 201 | + &format!("SELECT {RUNNER_COLS} FROM ci_runners WHERE id = ?1"), | |
| 202 | + (id.to_string(),), | |
| 203 | + map_runner, | |
| 204 | + ) | |
| 205 | + .await | |
| 206 | +} | |
| 207 | + | |
| 208 | +/// Moves blocked jobs on. A blocked job whose needs all succeeded becomes `Queued`. One whose | |
| 209 | +/// need ended without success, or is missing, becomes `Skipped`. Repeats until nothing changes, | |
| 210 | +/// so skips cascade to every dependant. | |
| 211 | +async fn propagate(conn: &Connection, run_id: &str, now: &str) -> Result<()> { | |
| 212 | + let jobs = jobs_of_run(conn, run_id).await?; | |
| 213 | + let needs: Vec<Vec<String>> = jobs.iter().map(|j| spec_needs(&j.spec_json)).collect(); | |
| 214 | + let mut states: Vec<JobState> = jobs.iter().map(|j| j.state).collect(); | |
| 215 | + loop { | |
| 216 | + let mut changed = false; | |
| 217 | + for i in 0..jobs.len() { | |
| 218 | + if states[i] != JobState::Blocked { | |
| 219 | + continue; | |
| 220 | + } | |
| 221 | + let mut all_success = true; | |
| 222 | + let mut dead = false; | |
| 223 | + for need in &needs[i] { | |
| 224 | + match jobs.iter().position(|j| &j.name == need).map(|k| states[k]) { | |
| 225 | + Some(JobState::Success) => {} | |
| 226 | + Some(s) if !s.is_terminal() => all_success = false, | |
| 227 | + _ => dead = true, | |
| 228 | + } | |
| 229 | + } | |
| 230 | + let next = if dead { | |
| 231 | + JobState::Skipped | |
| 232 | + } else if all_success { | |
| 233 | + JobState::Queued | |
| 234 | + } else { | |
| 235 | + continue; | |
| 236 | + }; | |
| 237 | + let n = if next == JobState::Queued { | |
| 238 | + conn.execute( | |
| 239 | + "UPDATE ci_jobs SET state = 'queued', queued_at = ?2 \ | |
| 240 | + WHERE id = ?1 AND state = 'blocked'", | |
| 241 | + (jobs[i].id.clone(), now.to_string()), | |
| 242 | + ) | |
| 243 | + .await | |
| 244 | + } else { | |
| 245 | + conn.execute( | |
| 246 | + "UPDATE ci_jobs SET state = 'skipped', finished_at = ?2 \ | |
| 247 | + WHERE id = ?1 AND state = 'blocked'", | |
| 248 | + (jobs[i].id.clone(), now.to_string()), | |
| 249 | + ) | |
| 250 | + .await | |
| 251 | + } | |
| 252 | + .map_err(db_err)?; | |
| 253 | + if n == 1 { | |
| 254 | + states[i] = next; | |
| 255 | + changed = true; | |
| 256 | + } | |
| 257 | + } | |
| 258 | + if !changed { | |
| 259 | + return Ok(()); | |
| 260 | + } | |
| 261 | + } | |
| 262 | +} | |
| 263 | + | |
| 264 | +/// Recomputes a live run's state from its jobs: any running, queued or blocked job makes it | |
| 265 | +/// `Running`; otherwise `Failure` if any job failed or errored, `Cancelled` if any was cancelled, | |
| 266 | +/// else `Success`. Terminal runs and config-error runs are left alone. | |
| 267 | +async fn recompute_run(conn: &Connection, run_id: &str, now: &str) -> Result<()> { | |
| 268 | + let run = load_run(conn, run_id).await?.ok_or(Error::NotFound)?; | |
| 269 | + if run.state.is_terminal() || run.config_error.is_some() { | |
| 270 | + return Ok(()); | |
| 271 | + } | |
| 272 | + let states: Vec<JobState> = fetch_all( | |
| 273 | + conn, | |
| 274 | + "SELECT state FROM ci_jobs WHERE run_id = ?1", | |
| 275 | + (run_id.to_string(),), | |
| 276 | + |row| col_text(row, 0)?.parse::<JobState>(), | |
| 277 | + ) | |
| 278 | + .await?; | |
| 279 | + let active = | |
| 280 | + |s: &JobState| matches!(s, JobState::Running | JobState::Queued | JobState::Blocked); | |
| 281 | + let next = if states.iter().any(active) { | |
| 282 | + RunState::Running | |
| 283 | + } else if states | |
| 284 | + .iter() | |
| 285 | + .any(|s| matches!(s, JobState::Failure | JobState::Error)) | |
| 286 | + { | |
| 287 | + RunState::Failure | |
| 288 | + } else if states.contains(&JobState::Cancelled) { | |
| 289 | + RunState::Cancelled | |
| 290 | + } else { | |
| 291 | + RunState::Success | |
| 292 | + }; | |
| 293 | + let finished = next.is_terminal().then(|| now.to_string()); | |
| 294 | + conn.execute( | |
| 295 | + "UPDATE ci_runs SET state = ?2, finished_at = ?3 WHERE id = ?1", | |
| 296 | + vec![ | |
| 297 | + text(run_id), | |
| 298 | + text(next.as_str()), | |
| 299 | + opt_text(finished.as_deref()), | |
| 300 | + ], | |
| 301 | + ) | |
| 302 | + .await | |
| 303 | + .map_err(db_err)?; | |
| 304 | + Ok(()) | |
| 305 | +} | |
| 306 | + | |
| 307 | +impl Store { | |
| 308 | + fn seal_ci_secret(&self, plain: &[u8]) -> Result<Vec<u8>> { | |
| 309 | + let key = self | |
| 310 | + .inner | |
| 311 | + .secret_key | |
| 312 | + .get() | |
| 313 | + .ok_or_else(|| Error::Crypto("instance secret key is not set".into()))?; | |
| 314 | + Ok(secret::seal(key, plain)?.into_bytes()) | |
| 315 | + } | |
| 316 | + | |
| 317 | + /// Creates a single-use registration token valid for `ttl_hours`. Returns the plaintext, | |
| 318 | + /// which is shown once. Only its sha256 is stored. | |
| 319 | + pub async fn create_registration_token( | |
| 320 | + &self, | |
| 321 | + created_by: &str, | |
| 322 | + ttl_hours: u32, | |
| 323 | + ) -> Result<String> { | |
| 324 | + if ttl_hours == 0 { | |
| 325 | + return Err(Error::Invalid( | |
| 326 | + "a registration token needs at least one hour of life".into(), | |
| 327 | + )); | |
| 328 | + } | |
| 329 | + let token = generate_token(); | |
| 330 | + let now = now_stamp(); | |
| 331 | + let expires = stamp_after(Duration::from_secs(u64::from(ttl_hours) * 3600)); | |
| 332 | + let conn = self.inner.conn.lock().await; | |
| 333 | + conn.execute( | |
| 334 | + "INSERT INTO ci_registration_tokens (token_hash, created_by, created_at, expires_at) \ | |
| 335 | + VALUES (?1, ?2, ?3, ?4)", | |
| 336 | + vec![ | |
| 337 | + text(&hash_token(&token)), | |
| 338 | + text(created_by), | |
| 339 | + text(&now), | |
| 340 | + text(&expires), | |
| 341 | + ], | |
| 342 | + ) | |
| 343 | + .await | |
| 344 | + .map_err(|e| insert_err(e, "registration token collision"))?; | |
| 345 | + Ok(token) | |
| 346 | + } | |
| 347 | + | |
| 348 | + /// Consumes a registration token and creates a runner. Returns the runner and its secret, | |
| 349 | + /// which is shown once. `Invalid` when the token is unknown, used or expired. | |
| 350 | + pub async fn register_runner( | |
| 351 | + &self, | |
| 352 | + token_plain: &str, | |
| 353 | + name: &str, | |
| 354 | + labels: &[String], | |
| 355 | + ) -> Result<(CiRunner, String)> { | |
| 356 | + let name = name.trim(); | |
| 357 | + if name.is_empty() || name.len() > MAX_RUNNER_NAME { | |
| 358 | + return Err(Error::Invalid(format!( | |
| 359 | + "a runner name needs 1 to {MAX_RUNNER_NAME} bytes" | |
| 360 | + ))); | |
| 361 | + } | |
| 362 | + if labels.iter().any(|l| l.is_empty() || l.len() > MAX_LABEL) { | |
| 363 | + return Err(Error::Invalid(format!( | |
| 364 | + "each runner label needs 1 to {MAX_LABEL} bytes" | |
| 365 | + ))); | |
| 366 | + } | |
| 367 | + let labels_json = | |
| 368 | + serde_json::to_string(labels).map_err(|e| Error::Database(e.to_string()))?; | |
| 369 | + let secret = generate_token(); | |
| 370 | + let secret_hash = hash_token(&secret); | |
| 371 | + let token_hash = hash_token(token_plain); | |
| 372 | + let id = new_id(); | |
| 373 | + let now = now_stamp(); | |
| 374 | + | |
| 375 | + let conn = self.inner.conn.lock().await; | |
| 376 | + let tx = conn.unchecked_transaction().await.map_err(db_err)?; | |
| 377 | + let outcome: Result<CiRunner> = async { | |
| 378 | + let used = tx | |
| 379 | + .execute( | |
| 380 | + "UPDATE ci_registration_tokens SET used_at = ?2 \ | |
| 381 | + WHERE token_hash = ?1 AND used_at IS NULL AND expires_at > ?3", | |
| 382 | + vec![text(&token_hash), text(&now), text(&now)], | |
| 383 | + ) | |
| 384 | + .await | |
| 385 | + .map_err(db_err)?; | |
| 386 | + if used != 1 { | |
| 387 | + return Err(Error::Invalid( | |
| 388 | + "the registration token is invalid, used or expired".into(), | |
| 389 | + )); | |
| 390 | + } | |
| 391 | + tx.execute( | |
| 392 | + "INSERT INTO ci_runners (id, name, labels, secret_hash, created_at, disabled) \ | |
| 393 | + VALUES (?1, ?2, ?3, ?4, ?5, 0)", | |
| 394 | + vec![ | |
| 395 | + text(&id), | |
| 396 | + text(name), | |
| 397 | + text(&labels_json), | |
| 398 | + text(&secret_hash), | |
| 399 | + text(&now), | |
| 400 | + ], | |
| 401 | + ) | |
| 402 | + .await | |
| 403 | + .map_err(|e| insert_err(e, "runner secret collision"))?; | |
| 404 | + load_runner(&tx, &id).await?.ok_or_else(missing_row) | |
| 405 | + } | |
| 406 | + .await; | |
| 407 | + let runner = finish(tx, outcome).await?; | |
| 408 | + Ok((runner, secret)) | |
| 409 | + } | |
| 410 | + | |
| 411 | + /// The runner with this secret, or `None`. Updates its `last_seen_at`. A disabled runner is | |
| 412 | + /// returned with `disabled: true`; the caller decides what to do with it. | |
| 413 | + pub async fn runner_by_secret(&self, secret_plain: &str) -> Result<Option<CiRunner>> { | |
| 414 | + let hash = hash_token(secret_plain); | |
| 415 | + let now = now_stamp(); | |
| 416 | + let conn = self.inner.conn.lock().await; | |
| 417 | + let found = fetch_opt( | |
| 418 | + &conn, | |
| 419 | + &format!("SELECT {RUNNER_COLS} FROM ci_runners WHERE secret_hash = ?1"), | |
| 420 | + (hash,), | |
| 421 | + map_runner, | |
| 422 | + ) | |
| 423 | + .await?; | |
| 424 | + let Some(runner) = found else { | |
| 425 | + return Ok(None); | |
| 426 | + }; | |
| 427 | + conn.execute( | |
| 428 | + "UPDATE ci_runners SET last_seen_at = ?2 WHERE id = ?1", | |
| 429 | + (runner.id.clone(), now.clone()), | |
| 430 | + ) | |
| 431 | + .await | |
| 432 | + .map_err(db_err)?; | |
| 433 | + Ok(Some(CiRunner { | |
| 434 | + last_seen_at: Some(now), | |
| 435 | + ..runner | |
| 436 | + })) | |
| 437 | + } | |
| 438 | + | |
| 439 | + /// All runners, by name. | |
| 440 | + pub async fn list_runners(&self) -> Result<Vec<CiRunner>> { | |
| 441 | + let conn = self.read_conn().await; | |
| 442 | + fetch_all( | |
| 443 | + &conn, | |
| 444 | + &format!("SELECT {RUNNER_COLS} FROM ci_runners ORDER BY name, id"), | |
| 445 | + (), | |
| 446 | + map_runner, | |
| 447 | + ) | |
| 448 | + .await | |
| 449 | + } | |
| 450 | + | |
| 451 | + /// Disabled runners get no jobs. `NotFound` if the runner does not exist. | |
| 452 | + pub async fn set_runner_disabled(&self, id: &str, disabled: bool) -> Result<()> { | |
| 453 | + let conn = self.inner.conn.lock().await; | |
| 454 | + let n = conn | |
| 455 | + .execute( | |
| 456 | + "UPDATE ci_runners SET disabled = ?2 WHERE id = ?1", | |
| 457 | + (id.to_string(), i64::from(disabled)), | |
| 458 | + ) | |
| 459 | + .await | |
| 460 | + .map_err(db_err)?; | |
| 461 | + if n == 0 { | |
| 462 | + return Err(Error::NotFound); | |
| 463 | + } | |
| 464 | + Ok(()) | |
| 465 | + } | |
| 466 | + | |
| 467 | + /// Removes a runner. `NotFound` if it does not exist. | |
| 468 | + pub async fn delete_runner(&self, id: &str) -> Result<()> { | |
| 469 | + let conn = self.inner.conn.lock().await; | |
| 470 | + let n = conn | |
| 471 | + .execute("DELETE FROM ci_runners WHERE id = ?1", (id.to_string(),)) | |
| 472 | + .await | |
| 473 | + .map_err(db_err)?; | |
| 474 | + if n == 0 { | |
| 475 | + return Err(Error::NotFound); | |
| 476 | + } | |
| 477 | + Ok(()) | |
| 478 | + } | |
| 479 | + | |
| 480 | + /// Creates a run with the next number for the repo, and its jobs, in one transaction. | |
| 481 | + /// | |
| 482 | + /// Initial states: a config error gives an `Error` run with no jobs. A run with no jobs | |
| 483 | + /// succeeds at once. With `needs_approval` the run waits and its need-less jobs are | |
| 484 | + /// `Blocked` until `approve_run`. Otherwise need-less jobs are `Queued` and the run is | |
| 485 | + /// `Queued`. Jobs with needs are always `Blocked` at first. | |
| 486 | + #[allow(clippy::too_many_arguments)] | |
| 487 | + pub async fn create_run( | |
| 488 | + &self, | |
| 489 | + repo_id: &str, | |
| 490 | + trigger: &str, | |
| 491 | + git_ref: &str, | |
| 492 | + commit_oid: &str, | |
| 493 | + pull_number: Option<i64>, | |
| 494 | + actor_id: &str, | |
| 495 | + needs_approval: bool, | |
| 496 | + jobs: &[NewJob<'_>], | |
| 497 | + config_error: Option<&str>, | |
| 498 | + ) -> Result<CiRun> { | |
| 499 | + let id = new_id(); | |
| 500 | + let now = now_stamp(); | |
| 501 | + let conn = self.inner.conn.lock().await; | |
| 502 | + let tx = conn.unchecked_transaction().await.map_err(db_err)?; | |
| 503 | + let outcome: Result<CiRun> = async { | |
| 504 | + let number = fetch_opt( | |
| 505 | + &tx, | |
| 506 | + "SELECT COALESCE(MAX(number), 0) + 1 FROM ci_runs WHERE repo_id = ?1", | |
| 507 | + (repo_id.to_string(),), | |
| 508 | + |row| col_int(row, 0), | |
| 509 | + ) | |
| 510 | + .await? | |
| 511 | + .ok_or_else(missing_row)?; | |
| 512 | + let (state, finished_at) = if config_error.is_some() { | |
| 513 | + (RunState::Error, Some(now.as_str())) | |
| 514 | + } else if jobs.is_empty() { | |
| 515 | + (RunState::Success, Some(now.as_str())) | |
| 516 | + } else if needs_approval { | |
| 517 | + (RunState::WaitingApproval, None) | |
| 518 | + } else { | |
| 519 | + (RunState::Queued, None) | |
| 520 | + }; | |
| 521 | + tx.execute( | |
| 522 | + "INSERT INTO ci_runs (id, repo_id, number, trigger, git_ref, commit_oid, \ | |
| 523 | + pull_number, actor_id, state, config_error, created_at, finished_at) \ | |
| 524 | + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", | |
| 525 | + vec![ | |
| 526 | + text(&id), | |
| 527 | + text(repo_id), | |
| 528 | + Value::Integer(number), | |
| 529 | + text(trigger), | |
| 530 | + text(git_ref), | |
| 531 | + text(commit_oid), | |
| 532 | + opt_int_value(pull_number), | |
| 533 | + text(actor_id), | |
| 534 | + text(state.as_str()), | |
| 535 | + opt_text(config_error), | |
| 536 | + text(&now), | |
| 537 | + opt_text(finished_at), | |
| 538 | + ], | |
| 539 | + ) | |
| 540 | + .await | |
| 541 | + .map_err(db_err)?; | |
| 542 | + | |
| 543 | + if config_error.is_none() { | |
| 544 | + for job in jobs { | |
| 545 | + let ready = job.needs.is_empty() && !needs_approval; | |
| 546 | + let (job_state, queued_at) = if ready { | |
| 547 | + (JobState::Queued, Some(now.as_str())) | |
| 548 | + } else { | |
| 549 | + (JobState::Blocked, None) | |
| 550 | + }; | |
| 551 | + tx.execute( | |
| 552 | + "INSERT INTO ci_jobs (id, run_id, name, spec, state, queued_at, \ | |
| 553 | + cancel_requested, log_bytes) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0, 0)", | |
| 554 | + vec![ | |
| 555 | + text(&new_id()), | |
| 556 | + text(&id), | |
| 557 | + text(job.name), | |
| 558 | + text(job.spec_json), | |
| 559 | + text(job_state.as_str()), | |
| 560 | + opt_text(queued_at), | |
| 561 | + ], | |
| 562 | + ) | |
| 563 | + .await | |
| 564 | + .map_err(|e| insert_err(e, "duplicate job name in this run"))?; | |
| 565 | + } | |
| 566 | + } | |
| 567 | + load_run(&tx, &id).await?.ok_or_else(missing_row) | |
| 568 | + } | |
| 569 | + .await; | |
| 570 | + finish(tx, outcome).await | |
| 571 | + } | |
| 572 | + | |
| 573 | + /// `WaitingApproval` -> `Queued`. Need-less jobs become `Queued`. `Conflict` when the run is | |
| 574 | + /// not waiting for approval. | |
| 575 | + pub async fn approve_run(&self, run_id: &str) -> Result<()> { | |
| 576 | + let now = now_stamp(); | |
| 577 | + let conn = self.inner.conn.lock().await; | |
| 578 | + let tx = conn.unchecked_transaction().await.map_err(db_err)?; | |
| 579 | + let outcome: Result<()> = async { | |
| 580 | + let run = load_run(&tx, run_id).await?.ok_or(Error::NotFound)?; | |
| 581 | + if run.state != RunState::WaitingApproval { | |
| 582 | + return Err(Error::Conflict( | |
| 583 | + "the run is not waiting for approval".into(), | |
| 584 | + )); | |
| 585 | + } | |
| 586 | + tx.execute( | |
| 587 | + "UPDATE ci_runs SET state = 'queued' WHERE id = ?1 AND state = 'waiting_approval'", | |
| 588 | + (run_id.to_string(),), | |
| 589 | + ) | |
| 590 | + .await | |
| 591 | + .map_err(db_err)?; | |
| 592 | + propagate(&tx, run_id, &now).await | |
| 593 | + } | |
| 594 | + .await; | |
| 595 | + finish(tx, outcome).await | |
| 596 | + } | |
| 597 | + | |
| 598 | + /// Claims the oldest queued job whose `runs_on` the runner's labels cover. The claim is an | |
| 599 | + /// UPDATE guarded by `state = 'queued'`, so only one claimer can win a job. Returns the job, | |
| 600 | + /// its run and the plaintext job token, which is shown once. A disabled runner gets nothing. | |
| 601 | + pub async fn claim_job(&self, runner: &CiRunner) -> Result<Option<(CiJob, CiRun, String)>> { | |
| 602 | + if runner.disabled { | |
| 603 | + return Ok(None); | |
| 604 | + } | |
| 605 | + let now = now_stamp(); | |
| 606 | + let token = generate_token(); | |
| 607 | + let token_hash = hash_token(&token); | |
| 608 | + let conn = self.inner.conn.lock().await; | |
| 609 | + let tx = conn.unchecked_transaction().await.map_err(db_err)?; | |
| 610 | + let outcome: Result<Option<(CiJob, CiRun)>> = async { | |
| 611 | + let queued: Vec<(String, String)> = fetch_all( | |
| 612 | + &tx, | |
| 613 | + "SELECT id, spec FROM ci_jobs WHERE state = 'queued' ORDER BY queued_at, id", | |
| 614 | + (), | |
| 615 | + |row| Ok((col_text(row, 0)?, col_text(row, 1)?)), | |
| 616 | + ) | |
| 617 | + .await?; | |
| 618 | + for (job_id, spec) in queued { | |
| 619 | + if !runs_on_covered(&spec, &runner.labels) { | |
| 620 | + continue; | |
| 621 | + } | |
| 622 | + let won = tx | |
| 623 | + .execute( | |
| 624 | + "UPDATE ci_jobs SET state = 'running', runner_id = ?2, job_token_hash = ?3, \ | |
| 625 | + started_at = ?4, heartbeat_at = ?4 WHERE id = ?1 AND state = 'queued'", | |
| 626 | + vec![ | |
| 627 | + text(&job_id), | |
| 628 | + text(&runner.id), | |
| 629 | + text(&token_hash), | |
| 630 | + text(&now), | |
| 631 | + ], | |
| 632 | + ) | |
| 633 | + .await | |
| 634 | + .map_err(db_err)?; | |
| 635 | + if won != 1 { | |
| 636 | + continue; | |
| 637 | + } | |
| 638 | + let job = load_job(&tx, &job_id).await?.ok_or_else(missing_row)?; | |
| 639 | + tx.execute( | |
| 640 | + "UPDATE ci_runs SET state = 'running' WHERE id = ?1 AND state = 'queued'", | |
| 641 | + (job.run_id.clone(),), | |
| 642 | + ) | |
| 643 | + .await | |
| 644 | + .map_err(db_err)?; | |
| 645 | + let run = load_run(&tx, &job.run_id).await?.ok_or_else(missing_row)?; | |
| 646 | + return Ok(Some((job, run))); | |
| 647 | + } | |
| 648 | + Ok(None) | |
| 649 | + } | |
| 650 | + .await; | |
| 651 | + let claimed = finish(tx, outcome).await?; | |
| 652 | + Ok(claimed.map(|(job, run)| (job, run, token))) | |
| 653 | + } | |
| 654 | + | |
| 655 | + /// The job, only while it is running and the token matches its stored hash. | |
| 656 | + pub async fn job_by_token(&self, job_id: &str, token_plain: &str) -> Result<Option<CiJob>> { | |
| 657 | + let conn = self.read_conn().await; | |
| 658 | + fetch_opt( | |
| 659 | + &conn, | |
| 660 | + &format!( | |
| 661 | + "SELECT {JOB_COLS} FROM ci_jobs \ | |
| 662 | + WHERE id = ?1 AND job_token_hash = ?2 AND state = 'running'" | |
| 663 | + ), | |
| 664 | + (job_id.to_string(), hash_token(token_plain)), | |
| 665 | + map_job, | |
| 666 | + ) | |
| 667 | + .await | |
| 668 | + } | |
| 669 | + | |
| 670 | + /// Stamps the job's heartbeat and returns whether a cancel was requested. `NotFound` when | |
| 671 | + /// the job is not running any more, so the runner stops. | |
| 672 | + pub async fn heartbeat(&self, job_id: &str) -> Result<bool> { | |
| 673 | + let now = now_stamp(); | |
| 674 | + let conn = self.inner.conn.lock().await; | |
| 675 | + let n = conn | |
| 676 | + .execute( | |
| 677 | + "UPDATE ci_jobs SET heartbeat_at = ?2 WHERE id = ?1 AND state = 'running'", | |
| 678 | + (job_id.to_string(), now), | |
| 679 | + ) | |
| 680 | + .await | |
| 681 | + .map_err(db_err)?; | |
| 682 | + if n != 1 { | |
| 683 | + return Err(Error::NotFound); | |
| 684 | + } | |
| 685 | + fetch_opt( | |
| 686 | + &conn, | |
| 687 | + "SELECT cancel_requested FROM ci_jobs WHERE id = ?1", | |
| 688 | + (job_id.to_string(),), | |
| 689 | + |row| col_bool(row, 0), | |
| 690 | + ) | |
| 691 | + .await? | |
| 692 | + .ok_or(Error::NotFound) | |
| 693 | + } | |
| 694 | + | |
| 695 | + /// Adds to the job's log size. | |
| 696 | + pub async fn add_log_bytes(&self, job_id: &str, n: i64) -> Result<()> { | |
| 697 | + if n < 0 { | |
| 698 | + return Err(Error::Invalid("log bytes cannot go down".into())); | |
| 699 | + } | |
| 700 | + let conn = self.inner.conn.lock().await; | |
| 701 | + let changed = conn | |
| 702 | + .execute( | |
| 703 | + "UPDATE ci_jobs SET log_bytes = log_bytes + ?2 WHERE id = ?1", | |
| 704 | + (job_id.to_string(), n), | |
| 705 | + ) | |
| 706 | + .await | |
| 707 | + .map_err(db_err)?; | |
| 708 | + if changed == 0 { | |
| 709 | + return Err(Error::NotFound); | |
| 710 | + } | |
| 711 | + Ok(()) | |
| 712 | + } | |
| 713 | + | |
| 714 | + /// Finishes a job with a terminal state. Dependants are then unblocked or skipped, and the | |
| 715 | + /// run state is recomputed. Returns the run. `Conflict` if the job already finished. | |
| 716 | + pub async fn finish_job( | |
| 717 | + &self, | |
| 718 | + job_id: &str, | |
| 719 | + state: JobState, | |
| 720 | + exit_code: Option<i64>, | |
| 721 | + message: Option<&str>, | |
| 722 | + ) -> Result<CiRun> { | |
| 723 | + if !state.is_terminal() { | |
| 724 | + return Err(Error::Invalid( | |
| 725 | + "a job can only finish in a terminal state".into(), | |
| 726 | + )); | |
| 727 | + } | |
| 728 | + let now = now_stamp(); | |
| 729 | + let conn = self.inner.conn.lock().await; | |
| 730 | + let tx = conn.unchecked_transaction().await.map_err(db_err)?; | |
| 731 | + let outcome: Result<CiRun> = async { | |
| 732 | + let job = load_job(&tx, job_id).await?.ok_or(Error::NotFound)?; | |
| 733 | + if job.state.is_terminal() { | |
| 734 | + return Err(Error::Conflict("the job has already finished".into())); | |
| 735 | + } | |
| 736 | + tx.execute( | |
| 737 | + "UPDATE ci_jobs SET state = ?2, exit_code = ?3, message = ?4, finished_at = ?5 \ | |
| 738 | + WHERE id = ?1", | |
| 739 | + vec![ | |
| 740 | + text(job_id), | |
| 741 | + text(state.as_str()), | |
| 742 | + opt_int_value(exit_code), | |
| 743 | + opt_text(message), | |
| 744 | + text(&now), | |
| 745 | + ], | |
| 746 | + ) | |
| 747 | + .await | |
| 748 | + .map_err(db_err)?; | |
| 749 | + propagate(&tx, &job.run_id, &now).await?; | |
| 750 | + recompute_run(&tx, &job.run_id, &now).await?; | |
| 751 | + load_run(&tx, &job.run_id).await?.ok_or_else(missing_row) | |
| 752 | + } | |
| 753 | + .await; | |
| 754 | + finish(tx, outcome).await | |
| 755 | + } | |
| 756 | + | |
| 757 | + /// Cancels the run's queued and blocked jobs now, and sets `cancel_requested` on its running | |
| 758 | + /// jobs. Does nothing to a run that already finished. | |
| 759 | + pub async fn request_cancel(&self, run_id: &str) -> Result<()> { | |
| 760 | + let now = now_stamp(); | |
| 761 | + let conn = self.inner.conn.lock().await; | |
| 762 | + let tx = conn.unchecked_transaction().await.map_err(db_err)?; | |
| 763 | + let outcome: Result<()> = async { | |
| 764 | + let run = load_run(&tx, run_id).await?.ok_or(Error::NotFound)?; | |
| 765 | + if run.state.is_terminal() { | |
| 766 | + return Ok(()); | |
| 767 | + } | |
| 768 | + tx.execute( | |
| 769 | + "UPDATE ci_jobs SET state = 'cancelled', finished_at = ?2 \ | |
| 770 | + WHERE run_id = ?1 AND state IN ('queued', 'blocked')", | |
| 771 | + (run_id.to_string(), now.clone()), | |
| 772 | + ) | |
| 773 | + .await | |
| 774 | + .map_err(db_err)?; | |
| 775 | + tx.execute( | |
| 776 | + "UPDATE ci_jobs SET cancel_requested = 1 WHERE run_id = ?1 AND state = 'running'", | |
| 777 | + (run_id.to_string(),), | |
| 778 | + ) | |
| 779 | + .await | |
| 780 | + .map_err(db_err)?; | |
| 781 | + recompute_run(&tx, run_id, &now).await | |
| 782 | + } | |
| 783 | + .await; | |
| 784 | + finish(tx, outcome).await | |
| 785 | + } | |
| 786 | + | |
| 787 | + /// A new run, with the next number, for the same commit, ref, trigger and job specs. The | |
| 788 | + /// new run is queued at once, without approval. | |
| 789 | + pub async fn rerun(&self, run_id: &str, actor_id: &str) -> Result<CiRun> { | |
| 790 | + let (run, jobs) = { | |
| 791 | + let conn = self.read_conn().await; | |
| 792 | + let run = load_run(&conn, run_id).await?.ok_or(Error::NotFound)?; | |
| 793 | + let jobs = jobs_of_run(&conn, run_id).await?; | |
| 794 | + (run, jobs) | |
| 795 | + }; | |
| 796 | + let owned: Vec<(String, String, Vec<String>)> = jobs | |
| 797 | + .iter() | |
| 798 | + .map(|j| { | |
| 799 | + ( | |
| 800 | + j.name.clone(), | |
| 801 | + j.spec_json.clone(), | |
| 802 | + spec_needs(&j.spec_json), | |
| 803 | + ) | |
| 804 | + }) | |
| 805 | + .collect(); | |
| 806 | + let new_jobs: Vec<NewJob<'_>> = owned | |
| 807 | + .iter() | |
| 808 | + .map(|(name, spec, needs)| NewJob { | |
| 809 | + name, | |
| 810 | + spec_json: spec, | |
| 811 | + needs, | |
| 812 | + }) | |
| 813 | + .collect(); | |
| 814 | + self.create_run( | |
| 815 | + &run.repo_id, | |
| 816 | + &run.trigger, | |
| 817 | + &run.git_ref, | |
| 818 | + &run.commit_oid, | |
| 819 | + run.pull_number, | |
| 820 | + actor_id, | |
| 821 | + false, | |
| 822 | + &new_jobs, | |
| 823 | + run.config_error.as_deref(), | |
| 824 | + ) | |
| 825 | + .await | |
| 826 | + } | |
| 827 | + | |
| 828 | + /// Running jobs whose heartbeat is older than `older_than` become `Error` with the message | |
| 829 | + /// "runner lost". Their dependants and run are updated as in `finish_job`. Returns the | |
| 830 | + /// reaped jobs. | |
| 831 | + pub async fn reap_lost_jobs(&self, older_than: &str) -> Result<Vec<CiJob>> { | |
| 832 | + let now = now_stamp(); | |
| 833 | + let conn = self.inner.conn.lock().await; | |
| 834 | + let tx = conn.unchecked_transaction().await.map_err(db_err)?; | |
| 835 | + let outcome: Result<Vec<CiJob>> = async { | |
| 836 | + let lost = fetch_all( | |
| 837 | + &tx, | |
| 838 | + &format!( | |
| 839 | + "SELECT {JOB_COLS} FROM ci_jobs \ | |
| 840 | + WHERE state = 'running' AND heartbeat_at < ?1 ORDER BY id" | |
| 841 | + ), | |
| 842 | + (older_than.to_string(),), | |
| 843 | + map_job, | |
| 844 | + ) | |
| 845 | + .await?; | |
| 846 | + let mut runs: Vec<String> = Vec::new(); | |
| 847 | + for job in &lost { | |
| 848 | + tx.execute( | |
| 849 | + "UPDATE ci_jobs SET state = 'error', message = 'runner lost', finished_at = ?2 \ | |
| 850 | + WHERE id = ?1 AND state = 'running'", | |
| 851 | + (job.id.clone(), now.clone()), | |
| 852 | + ) | |
| 853 | + .await | |
| 854 | + .map_err(db_err)?; | |
| 855 | + if !runs.contains(&job.run_id) { | |
| 856 | + runs.push(job.run_id.clone()); | |
| 857 | + } | |
| 858 | + } | |
| 859 | + for run_id in &runs { | |
| 860 | + propagate(&tx, run_id, &now).await?; | |
| 861 | + recompute_run(&tx, run_id, &now).await?; | |
| 862 | + } | |
| 863 | + let mut out = Vec::with_capacity(lost.len()); | |
| 864 | + for job in &lost { | |
| 865 | + out.push(load_job(&tx, &job.id).await?.ok_or_else(missing_row)?); | |
| 866 | + } | |
| 867 | + Ok(out) | |
| 868 | + } | |
| 869 | + .await; | |
| 870 | + finish(tx, outcome).await | |
| 871 | + } | |
| 872 | + | |
| 873 | + pub async fn run_by_number(&self, repo_id: &str, n: i64) -> Result<Option<CiRun>> { | |
| 874 | + let conn = self.read_conn().await; | |
| 875 | + fetch_opt( | |
| 876 | + &conn, | |
| 877 | + &format!("SELECT {RUN_COLS} FROM ci_runs WHERE repo_id = ?1 AND number = ?2"), | |
| 878 | + (repo_id.to_string(), n), | |
| 879 | + map_run, | |
| 880 | + ) | |
| 881 | + .await | |
| 882 | + } | |
| 883 | + | |
| 884 | + pub async fn run_by_id(&self, id: &str) -> Result<Option<CiRun>> { | |
| 885 | + let conn = self.read_conn().await; | |
| 886 | + load_run(&conn, id).await | |
| 887 | + } | |
| 888 | + | |
| 889 | + /// One page of a repo's runs, newest first, and the total count. `page` starts at 1 and | |
| 890 | + /// `per_page` is capped at 100. | |
| 891 | + pub async fn list_runs( | |
| 892 | + &self, | |
| 893 | + repo_id: &str, | |
| 894 | + page: u32, | |
| 895 | + per_page: u32, | |
| 896 | + ) -> Result<(Vec<CiRun>, u64)> { | |
| 897 | + let per_page = per_page.clamp(1, MAX_PER_PAGE); | |
| 898 | + let offset = i64::from(page.max(1).saturating_sub(1)) * i64::from(per_page); | |
| 899 | + let conn = self.read_conn().await; | |
| 900 | + let total = fetch_opt( | |
| 901 | + &conn, | |
| 902 | + "SELECT COUNT(*) FROM ci_runs WHERE repo_id = ?1", | |
| 903 | + (repo_id.to_string(),), | |
| 904 | + |row| col_int(row, 0), | |
| 905 | + ) | |
| 906 | + .await? | |
| 907 | + .ok_or_else(missing_row)?; | |
| 908 | + let runs = fetch_all( | |
| 909 | + &conn, | |
| 910 | + &format!( | |
| 911 | + "SELECT {RUN_COLS} FROM ci_runs WHERE repo_id = ?1 \ | |
| 912 | + ORDER BY number DESC LIMIT ?2 OFFSET ?3" | |
| 913 | + ), | |
| 914 | + (repo_id.to_string(), i64::from(per_page), offset), | |
| 915 | + map_run, | |
| 916 | + ) | |
| 917 | + .await?; | |
| 918 | + let total = | |
| 919 | + u64::try_from(total).map_err(|_| Error::Database("negative run count".into()))?; | |
| 920 | + Ok((runs, total)) | |
| 921 | + } | |
| 922 | + | |
| 923 | + /// The jobs of a run, by name. | |
| 924 | + pub async fn jobs_for_run(&self, run_id: &str) -> Result<Vec<CiJob>> { | |
| 925 | + let conn = self.read_conn().await; | |
| 926 | + jobs_of_run(&conn, run_id).await | |
| 927 | + } | |
| 928 | + | |
| 929 | + pub async fn job_by_id(&self, id: &str) -> Result<Option<CiJob>> { | |
| 930 | + let conn = self.read_conn().await; | |
| 931 | + load_job(&conn, id).await | |
| 932 | + } | |
| 933 | + | |
| 934 | + /// Records an artifact of a job. `Conflict` if the job already has one with this name. | |
| 935 | + pub async fn add_artifact( | |
| 936 | + &self, | |
| 937 | + job_id: &str, | |
| 938 | + name: &str, | |
| 939 | + size: i64, | |
| 940 | + sha256: &str, | |
| 941 | + ) -> Result<CiArtifact> { | |
| 942 | + if size < 0 { | |
| 943 | + return Err(Error::Invalid("an artifact size cannot be negative".into())); | |
| 944 | + } | |
| 945 | + let artifact = CiArtifact { | |
| 946 | + id: new_id(), | |
| 947 | + job_id: job_id.to_string(), | |
| 948 | + name: name.to_string(), | |
| 949 | + size, | |
| 950 | + sha256: sha256.to_string(), | |
| 951 | + created_at: now_stamp(), | |
| 952 | + }; | |
| 953 | + let conn = self.inner.conn.lock().await; | |
| 954 | + conn.execute( | |
| 955 | + "INSERT INTO ci_artifacts (id, job_id, name, size, sha256, created_at) \ | |
| 956 | + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", | |
| 957 | + vec![ | |
| 958 | + text(&artifact.id), | |
| 959 | + text(&artifact.job_id), | |
| 960 | + text(&artifact.name), | |
| 961 | + Value::Integer(artifact.size), | |
| 962 | + text(&artifact.sha256), | |
| 963 | + text(&artifact.created_at), | |
| 964 | + ], | |
| 965 | + ) | |
| 966 | + .await | |
| 967 | + .map_err(|e| insert_err(e, "this job already has an artifact with that name"))?; | |
| 968 | + Ok(artifact) | |
| 969 | + } | |
| 970 | + | |
| 971 | + /// The artifacts of a job, by name. | |
| 972 | + pub async fn list_artifacts(&self, job_id: &str) -> Result<Vec<CiArtifact>> { | |
| 973 | + let conn = self.read_conn().await; | |
| 974 | + fetch_all( | |
| 975 | + &conn, | |
| 976 | + &format!("SELECT {ARTIFACT_COLS} FROM ci_artifacts WHERE job_id = ?1 ORDER BY name"), | |
| 977 | + (job_id.to_string(),), | |
| 978 | + map_artifact, | |
| 979 | + ) | |
| 980 | + .await | |
| 981 | + } | |
| 982 | + | |
| 983 | + /// One artifact of a job, by name. | |
| 984 | + pub async fn artifact(&self, job_id: &str, name: &str) -> Result<Option<CiArtifact>> { | |
| 985 | + let conn = self.read_conn().await; | |
| 986 | + fetch_opt( | |
| 987 | + &conn, | |
| 988 | + &format!("SELECT {ARTIFACT_COLS} FROM ci_artifacts WHERE job_id = ?1 AND name = ?2"), | |
| 989 | + (job_id.to_string(), name.to_string()), | |
| 990 | + map_artifact, | |
| 991 | + ) | |
| 992 | + .await | |
| 993 | + } | |
| 994 | + | |
| 995 | + /// Retention. Jobs of runs that finished before `logs_before` and still have logs get | |
| 996 | + /// `log_bytes = 0`; their ids are returned so the caller deletes the log files. Artifact rows | |
| 997 | + /// created before `artifacts_before` are deleted; their ids are returned so the caller | |
| 998 | + /// deletes the files. | |
| 999 | + pub async fn purge_old( | |
| 1000 | + &self, | |
| 1001 | + logs_before: &str, | |
| 1002 | + artifacts_before: &str, | |
| 1003 | + ) -> Result<(Vec<String>, Vec<String>)> { | |
| 1004 | + let conn = self.inner.conn.lock().await; | |
| 1005 | + let tx = conn.unchecked_transaction().await.map_err(db_err)?; | |
| 1006 | + let outcome: Result<(Vec<String>, Vec<String>)> = async { | |
| 1007 | + let job_ids = fetch_all( | |
| 1008 | + &tx, | |
| 1009 | + "SELECT j.id FROM ci_jobs j JOIN ci_runs r ON r.id = j.run_id \ | |
| 1010 | + WHERE r.finished_at IS NOT NULL AND r.finished_at < ?1 AND j.log_bytes > 0 \ | |
| 1011 | + ORDER BY j.id", | |
| 1012 | + (logs_before.to_string(),), | |
| 1013 | + |row| col_text(row, 0), | |
| 1014 | + ) | |
| 1015 | + .await?; | |
| 1016 | + for id in &job_ids { | |
| 1017 | + tx.execute( | |
| 1018 | + "UPDATE ci_jobs SET log_bytes = 0 WHERE id = ?1", | |
| 1019 | + (id.clone(),), | |
| 1020 | + ) | |
| 1021 | + .await | |
| 1022 | + .map_err(db_err)?; | |
| 1023 | + } | |
| 1024 | + let artifact_ids = fetch_all( | |
| 1025 | + &tx, | |
| 1026 | + "SELECT id FROM ci_artifacts WHERE created_at < ?1 ORDER BY id", | |
| 1027 | + (artifacts_before.to_string(),), | |
| 1028 | + |row| col_text(row, 0), | |
| 1029 | + ) | |
| 1030 | + .await?; | |
| 1031 | + tx.execute( | |
| 1032 | + "DELETE FROM ci_artifacts WHERE created_at < ?1", | |
| 1033 | + (artifacts_before.to_string(),), | |
| 1034 | + ) | |
| 1035 | + .await | |
| 1036 | + .map_err(db_err)?; | |
| 1037 | + Ok((job_ids, artifact_ids)) | |
| 1038 | + } | |
| 1039 | + .await; | |
| 1040 | + finish(tx, outcome).await | |
| 1041 | + } | |
| 1042 | + | |
| 1043 | + /// Stores or replaces a repo secret. Sealed with the instance key. The name must match the | |
| 1044 | + /// JobSpec secret rule, and the value must be UTF-8. | |
| 1045 | + pub async fn set_ci_secret(&self, repo_id: &str, name: &str, value: &[u8]) -> Result<()> { | |
| 1046 | + validate_secret_name(name)?; | |
| 1047 | + std::str::from_utf8(value) | |
| 1048 | + .map_err(|_| Error::Invalid("a CI secret value must be UTF-8".into()))?; | |
| 1049 | + let sealed = self.seal_ci_secret(value)?; | |
| 1050 | + let now = now_stamp(); | |
| 1051 | + let conn = self.inner.conn.lock().await; | |
| 1052 | + conn.execute( | |
| 1053 | + "INSERT INTO ci_secrets (repo_id, name, sealed, created_at, updated_at) \ | |
| 1054 | + VALUES (?1, ?2, ?3, ?4, ?5) \ | |
| 1055 | + ON CONFLICT(repo_id, name) DO UPDATE SET sealed = excluded.sealed, \ | |
| 1056 | + updated_at = excluded.updated_at", | |
| 1057 | + vec![ | |
| 1058 | + text(repo_id), | |
| 1059 | + text(name), | |
| 1060 | + Value::Blob(sealed), | |
| 1061 | + text(&now), | |
| 1062 | + text(&now), | |
| 1063 | + ], | |
| 1064 | + ) | |
| 1065 | + .await | |
| 1066 | + .map_err(db_err)?; | |
| 1067 | + Ok(()) | |
| 1068 | + } | |
| 1069 | + | |
| 1070 | + /// Deletes a repo secret. Returns whether one existed. | |
| 1071 | + pub async fn delete_ci_secret(&self, repo_id: &str, name: &str) -> Result<bool> { | |
| 1072 | + let conn = self.inner.conn.lock().await; | |
| 1073 | + let n = conn | |
| 1074 | + .execute( | |
| 1075 | + "DELETE FROM ci_secrets WHERE repo_id = ?1 AND name = ?2", | |
| 1076 | + (repo_id.to_string(), name.to_string()), | |
| 1077 | + ) | |
| 1078 | + .await | |
| 1079 | + .map_err(db_err)?; | |
| 1080 | + Ok(n > 0) | |
| 1081 | + } | |
| 1082 | + | |
| 1083 | + /// Secret names and their `updated_at`, by name. Values are never read. | |
| 1084 | + pub async fn list_ci_secret_names(&self, repo_id: &str) -> Result<Vec<(String, String)>> { | |
| 1085 | + let conn = self.read_conn().await; | |
| 1086 | + fetch_all( | |
| 1087 | + &conn, | |
| 1088 | + "SELECT name, updated_at FROM ci_secrets WHERE repo_id = ?1 ORDER BY name", | |
| 1089 | + (repo_id.to_string(),), | |
| 1090 | + |row| Ok((col_text(row, 0)?, col_text(row, 1)?)), | |
| 1091 | + ) | |
| 1092 | + .await | |
| 1093 | + } | |
| 1094 | + | |
| 1095 | + /// Opened values of the named secrets. Names that do not exist are omitted. For the claim | |
| 1096 | + /// path only. | |
| 1097 | + pub async fn ci_secret_values( | |
| 1098 | + &self, | |
| 1099 | + repo_id: &str, | |
| 1100 | + names: &[String], | |
| 1101 | + ) -> Result<BTreeMap<String, String>> { | |
| 1102 | + let rows: Vec<(String, Vec<u8>)> = { | |
| 1103 | + let conn = self.read_conn().await; | |
| 1104 | + fetch_all( | |
| 1105 | + &conn, | |
| 1106 | + "SELECT name, sealed FROM ci_secrets WHERE repo_id = ?1", | |
| 1107 | + (repo_id.to_string(),), | |
| 1108 | + |row| Ok((col_text(row, 0)?, col_blob(row, 1)?)), | |
| 1109 | + ) | |
| 1110 | + .await? | |
| 1111 | + }; | |
| 1112 | + let mut out = BTreeMap::new(); | |
| 1113 | + for (name, sealed) in rows { | |
| 1114 | + if !names.contains(&name) { | |
| 1115 | + continue; | |
| 1116 | + } | |
| 1117 | + let key = self | |
| 1118 | + .inner | |
| 1119 | + .secret_key | |
| 1120 | + .get() | |
| 1121 | + .ok_or_else(|| Error::Crypto("instance secret key is not set".into()))?; | |
| 1122 | + let sealed = String::from_utf8(sealed) | |
| 1123 | + .map_err(|_| Error::Crypto("sealed CI secret is not UTF-8".into()))?; | |
| 1124 | + let plain = secret::open(key, &sealed)?; | |
| 1125 | + let value = String::from_utf8(plain) | |
| 1126 | + .map_err(|_| Error::Crypto("CI secret is not UTF-8".into()))?; | |
| 1127 | + out.insert(name, value); | |
| 1128 | + } | |
| 1129 | + Ok(out) | |
| 1130 | + } | |
| 1131 | +} | |
| 1132 | + | |
| 1133 | +#[cfg(test)] | |
| 1134 | +mod tests { | |
| 1135 | + use super::*; | |
| 1136 | + use crate::model::{Repo, User, Visibility}; | |
| 1137 | + use crate::token::hash_token; | |
| 1138 | + | |
| 1139 | + const KEY: [u8; 32] = [9u8; 32]; | |
| 1140 | + const PAST: &str = "2000-01-01T00:00:00.000000000Z"; | |
| 1141 | + const DAWN: &str = "1970-01-01T00:00:00.000000000Z"; | |
| 1142 | + | |
| 1143 | + struct Fx { | |
| 1144 | + _dir: tempfile::TempDir, | |
| 1145 | + store: Store, | |
| 1146 | + alice: User, | |
| 1147 | + repo: Repo, | |
| 1148 | + } | |
| 1149 | + | |
| 1150 | + async fn fixture() -> Fx { | |
| 1151 | + let dir = tempfile::tempdir().unwrap(); | |
| 1152 | + let store = Store::open(&dir.path().join("forge.db")).await.unwrap(); | |
| 1153 | + store.set_secret_key(KEY); | |
| 1154 | + let alice = store | |
| 1155 | + .create_user("Alice", None, "hash", false) | |
| 1156 | + .await | |
| 1157 | + .unwrap(); | |
| 1158 | + let repo = store | |
| 1159 | + .create_repo(&alice.id, "proj", "", Visibility::Public, "main") | |
| 1160 | + .await | |
| 1161 | + .unwrap(); | |
| 1162 | + Fx { | |
| 1163 | + _dir: dir, | |
| 1164 | + store, | |
| 1165 | + alice, | |
| 1166 | + repo, | |
| 1167 | + } | |
| 1168 | + } | |
| 1169 | + | |
| 1170 | + struct Job { | |
| 1171 | + name: String, | |
| 1172 | + spec: String, | |
| 1173 | + needs: Vec<String>, | |
| 1174 | + } | |
| 1175 | + | |
| 1176 | + fn job(name: &str, needs: &[&str], runs_on: &[&str]) -> Job { | |
| 1177 | + Job { | |
| 1178 | + name: name.to_string(), | |
| 1179 | + spec: serde_json::json!({ | |
| 1180 | + "image": "alpine", | |
| 1181 | + "run": "true", | |
| 1182 | + "needs": needs, | |
| 1183 | + "runs_on": runs_on, | |
| 1184 | + }) | |
| 1185 | + .to_string(), | |
| 1186 | + needs: needs.iter().map(|s| s.to_string()).collect(), | |
| 1187 | + } | |
| 1188 | + } | |
| 1189 | + | |
| 1190 | + async fn create( | |
| 1191 | + store: &Store, | |
| 1192 | + repo_id: &str, | |
| 1193 | + actor: &str, | |
| 1194 | + jobs: &[Job], | |
| 1195 | + approval: bool, | |
| 1196 | + ) -> CiRun { | |
| 1197 | + let new: Vec<NewJob<'_>> = jobs | |
| 1198 | + .iter() | |
| 1199 | + .map(|j| NewJob { | |
| 1200 | + name: &j.name, | |
| 1201 | + spec_json: &j.spec, | |
| 1202 | + needs: &j.needs, | |
| 1203 | + }) | |
| 1204 | + .collect(); | |
| 1205 | + store | |
| 1206 | + .create_run( | |
| 1207 | + repo_id, | |
| 1208 | + "push", | |
| 1209 | + "refs/heads/main", | |
| 1210 | + "abc123", | |
| 1211 | + None, | |
| 1212 | + actor, | |
| 1213 | + approval, | |
| 1214 | + &new, | |
| 1215 | + None, | |
| 1216 | + ) | |
| 1217 | + .await | |
| 1218 | + .unwrap() | |
| 1219 | + } | |
| 1220 | + | |
| 1221 | + async fn job_named(store: &Store, run_id: &str, name: &str) -> CiJob { | |
| 1222 | + store | |
| 1223 | + .jobs_for_run(run_id) | |
| 1224 | + .await | |
| 1225 | + .unwrap() | |
| 1226 | + .into_iter() | |
| 1227 | + .find(|j| j.name == name) | |
| 1228 | + .unwrap() | |
| 1229 | + } | |
| 1230 | + | |
| 1231 | + async fn runner_with(store: &Store, actor: &str, labels: &[&str]) -> (CiRunner, String) { | |
| 1232 | + let token = store.create_registration_token(actor, 1).await.unwrap(); | |
| 1233 | + let labels: Vec<String> = labels.iter().map(|s| s.to_string()).collect(); | |
| 1234 | + store | |
| 1235 | + .register_runner(&token, "runner", &labels) | |
| 1236 | + .await | |
| 1237 | + .unwrap() | |
| 1238 | + } | |
| 1239 | + | |
| 1240 | + fn bare_runner(labels: &[&str]) -> CiRunner { | |
| 1241 | + CiRunner { | |
| 1242 | + id: new_id(), | |
| 1243 | + name: "bare".into(), | |
| 1244 | + labels: labels.iter().map(|s| s.to_string()).collect(), | |
| 1245 | + created_at: now_stamp(), | |
| 1246 | + last_seen_at: None, | |
| 1247 | + disabled: false, | |
| 1248 | + } | |
| 1249 | + } | |
| 1250 | + | |
| 1251 | + #[tokio::test] | |
| 1252 | + async fn registration_token_is_single_use_and_expires() { | |
| 1253 | + let fx = fixture().await; | |
| 1254 | + let token = fx | |
| 1255 | + .store | |
| 1256 | + .create_registration_token(&fx.alice.id, 1) | |
| 1257 | + .await | |
| 1258 | + .unwrap(); | |
| 1259 | + // Only the hash is stored. | |
| 1260 | + { | |
| 1261 | + let conn = fx.store.inner.conn.lock().await; | |
| 1262 | + let n = fetch_opt( | |
| 1263 | + &conn, | |
| 1264 | + "SELECT COUNT(*) FROM ci_registration_tokens WHERE token_hash = ?1", | |
| 1265 | + (token.clone(),), | |
| 1266 | + |row| col_int(row, 0), | |
| 1267 | + ) | |
| 1268 | + .await | |
| 1269 | + .unwrap() | |
| 1270 | + .unwrap(); | |
| 1271 | + assert_eq!(n, 0); | |
| 1272 | + } | |
| 1273 | + let (runner, _secret) = fx | |
| 1274 | + .store | |
| 1275 | + .register_runner(&token, "build-1", &["linux".to_string()]) | |
| 1276 | + .await | |
| 1277 | + .unwrap(); | |
| 1278 | + assert_eq!(runner.name, "build-1"); | |
| 1279 | + assert_eq!(runner.labels, vec!["linux".to_string()]); | |
| 1280 | + let again = fx.store.register_runner(&token, "build-2", &[]).await; | |
| 1281 | + assert!(matches!(again, Err(Error::Invalid(_))), "{again:?}"); | |
| 1282 | + | |
| 1283 | + let expired = fx | |
| 1284 | + .store | |
| 1285 | + .create_registration_token(&fx.alice.id, 1) | |
| 1286 | + .await | |
| 1287 | + .unwrap(); | |
| 1288 | + { | |
| 1289 | + let conn = fx.store.inner.conn.lock().await; | |
| 1290 | + conn.execute( | |
| 1291 | + "UPDATE ci_registration_tokens SET expires_at = ?2 WHERE token_hash = ?1", | |
| 1292 | + (hash_token(&expired), PAST.to_string()), | |
| 1293 | + ) | |
| 1294 | + .await | |
| 1295 | + .unwrap(); | |
| 1296 | + } | |
| 1297 | + let late = fx.store.register_runner(&expired, "late", &[]).await; | |
| 1298 | + assert!(matches!(late, Err(Error::Invalid(_))), "{late:?}"); | |
| 1299 | + assert!(matches!( | |
| 1300 | + fx.store.create_registration_token(&fx.alice.id, 0).await, | |
| 1301 | + Err(Error::Invalid(_)) | |
| 1302 | + )); | |
| 1303 | + } | |
| 1304 | + | |
| 1305 | + #[tokio::test] | |
| 1306 | + async fn runner_secret_lookup_and_disable() { | |
| 1307 | + let fx = fixture().await; | |
| 1308 | + let (runner, secret) = runner_with(&fx.store, &fx.alice.id, &["linux"]).await; | |
| 1309 | + let found = fx.store.runner_by_secret(&secret).await.unwrap().unwrap(); | |
| 1310 | + assert_eq!(found.id, runner.id); | |
| 1311 | + assert!(found.last_seen_at.is_some()); | |
| 1312 | + assert!( | |
| 1313 | + fx.store | |
| 1314 | + .runner_by_secret("forge_wrong") | |
| 1315 | + .await | |
| 1316 | + .unwrap() | |
| 1317 | + .is_none() | |
| 1318 | + ); | |
| 1319 | + { | |
| 1320 | + let conn = fx.store.inner.conn.lock().await; | |
| 1321 | + let n = fetch_opt( | |
| 1322 | + &conn, | |
| 1323 | + "SELECT COUNT(*) FROM ci_runners WHERE secret_hash = ?1", | |
| 1324 | + (secret.clone(),), | |
| 1325 | + |row| col_int(row, 0), | |
| 1326 | + ) | |
| 1327 | + .await | |
| 1328 | + .unwrap() | |
| 1329 | + .unwrap(); | |
| 1330 | + assert_eq!(n, 0, "the plaintext secret must not be stored"); | |
| 1331 | + } | |
| 1332 | + fx.store | |
| 1333 | + .set_runner_disabled(&runner.id, true) | |
| 1334 | + .await | |
| 1335 | + .unwrap(); | |
| 1336 | + let listed = fx.store.list_runners().await.unwrap(); | |
| 1337 | + assert_eq!(listed.len(), 1); | |
| 1338 | + assert!(listed[0].disabled); | |
| 1339 | + let disabled = fx.store.runner_by_secret(&secret).await.unwrap().unwrap(); | |
| 1340 | + assert!(disabled.disabled); | |
| 1341 | + fx.store.delete_runner(&runner.id).await.unwrap(); | |
| 1342 | + assert!(fx.store.runner_by_secret(&secret).await.unwrap().is_none()); | |
| 1343 | + assert!(matches!( | |
| 1344 | + fx.store.delete_runner(&runner.id).await, | |
| 1345 | + Err(Error::NotFound) | |
| 1346 | + )); | |
| 1347 | + } | |
| 1348 | + | |
| 1349 | + #[tokio::test] | |
| 1350 | + async fn create_run_numbers_per_repo() { | |
| 1351 | + let fx = fixture().await; | |
| 1352 | + let other = fx | |
| 1353 | + .store | |
| 1354 | + .create_repo(&fx.alice.id, "other", "", Visibility::Public, "main") | |
| 1355 | + .await | |
| 1356 | + .unwrap(); | |
| 1357 | + let jobs = [job("build", &[], &[]), job("test", &["build"], &[])]; | |
| 1358 | + let r1 = create(&fx.store, &fx.repo.id, &fx.alice.id, &jobs, false).await; | |
| 1359 | + let r2 = create(&fx.store, &fx.repo.id, &fx.alice.id, &jobs, false).await; | |
| 1360 | + let o1 = create(&fx.store, &other.id, &fx.alice.id, &jobs, false).await; | |
| 1361 | + assert_eq!((r1.number, r2.number, o1.number), (1, 2, 1)); | |
| 1362 | + assert_eq!(r1.state, RunState::Queued); | |
| 1363 | + assert_eq!( | |
| 1364 | + job_named(&fx.store, &r1.id, "build").await.state, | |
| 1365 | + JobState::Queued | |
| 1366 | + ); | |
| 1367 | + assert_eq!( | |
| 1368 | + job_named(&fx.store, &r1.id, "test").await.state, | |
| 1369 | + JobState::Blocked | |
| 1370 | + ); | |
| 1371 | + assert_eq!( | |
| 1372 | + fx.store | |
| 1373 | + .run_by_number(&fx.repo.id, 2) | |
| 1374 | + .await | |
| 1375 | + .unwrap() | |
| 1376 | + .unwrap() | |
| 1377 | + .id, | |
| 1378 | + r2.id | |
| 1379 | + ); | |
| 1380 | + | |
| 1381 | + let broken = fx | |
| 1382 | + .store | |
| 1383 | + .create_run( | |
| 1384 | + &fx.repo.id, | |
| 1385 | + "push", | |
| 1386 | + "refs/heads/main", | |
| 1387 | + "abc123", | |
| 1388 | + None, | |
| 1389 | + &fx.alice.id, | |
| 1390 | + false, | |
| 1391 | + &[], | |
| 1392 | + Some("line 3: unknown key"), | |
| 1393 | + ) | |
| 1394 | + .await | |
| 1395 | + .unwrap(); | |
| 1396 | + assert_eq!(broken.number, 3); | |
| 1397 | + assert_eq!(broken.state, RunState::Error); | |
| 1398 | + assert!(broken.finished_at.is_some()); | |
| 1399 | + assert!(fx.store.jobs_for_run(&broken.id).await.unwrap().is_empty()); | |
| 1400 | + | |
| 1401 | + let (page, total) = fx.store.list_runs(&fx.repo.id, 1, 2).await.unwrap(); | |
| 1402 | + assert_eq!(total, 3); | |
| 1403 | + assert_eq!( | |
| 1404 | + page.iter().map(|r| r.number).collect::<Vec<_>>(), | |
| 1405 | + vec![3, 2] | |
| 1406 | + ); | |
| 1407 | + let (page2, _) = fx.store.list_runs(&fx.repo.id, 2, 2).await.unwrap(); | |
| 1408 | + assert_eq!(page2.iter().map(|r| r.number).collect::<Vec<_>>(), vec![1]); | |
| 1409 | + } | |
| 1410 | + | |
| 1411 | + #[tokio::test] | |
| 1412 | + async fn approval_flow() { | |
| 1413 | + let fx = fixture().await; | |
| 1414 | + let jobs = [job("build", &[], &[]), job("test", &["build"], &[])]; | |
| 1415 | + let run = create(&fx.store, &fx.repo.id, &fx.alice.id, &jobs, true).await; | |
| 1416 | + assert_eq!(run.state, RunState::WaitingApproval); | |
| 1417 | + assert_eq!( | |
| 1418 | + job_named(&fx.store, &run.id, "build").await.state, | |
| 1419 | + JobState::Blocked | |
| 1420 | + ); | |
| 1421 | + | |
| 1422 | + fx.store.approve_run(&run.id).await.unwrap(); | |
| 1423 | + let after = fx.store.run_by_id(&run.id).await.unwrap().unwrap(); | |
| 1424 | + assert_eq!(after.state, RunState::Queued); | |
| 1425 | + assert_eq!( | |
| 1426 | + job_named(&fx.store, &run.id, "build").await.state, | |
| 1427 | + JobState::Queued | |
| 1428 | + ); | |
| 1429 | + assert_eq!( | |
| 1430 | + job_named(&fx.store, &run.id, "test").await.state, | |
| 1431 | + JobState::Blocked | |
| 1432 | + ); | |
| 1433 | + assert!(matches!( | |
| 1434 | + fx.store.approve_run(&run.id).await, | |
| 1435 | + Err(Error::Conflict(_)) | |
| 1436 | + )); | |
| 1437 | + assert!(matches!( | |
| 1438 | + fx.store.approve_run("missing").await, | |
| 1439 | + Err(Error::NotFound) | |
| 1440 | + )); | |
| 1441 | + } | |
| 1442 | + | |
| 1443 | + #[tokio::test] | |
| 1444 | + async fn claim_matches_labels_and_skips_disabled() { | |
| 1445 | + let fx = fixture().await; | |
| 1446 | + let run = create( | |
| 1447 | + &fx.store, | |
| 1448 | + &fx.repo.id, | |
| 1449 | + &fx.alice.id, | |
| 1450 | + &[ | |
| 1451 | + job("arm", &[], &["linux", "arm"]), | |
| 1452 | + job("x86", &[], &["linux"]), | |
| 1453 | + ], | |
| 1454 | + false, | |
| 1455 | + ) | |
| 1456 | + .await; | |
| 1457 | + let linux_only = bare_runner(&["linux"]); | |
| 1458 | + let claimed = fx.store.claim_job(&linux_only).await.unwrap().unwrap(); | |
| 1459 | + assert_eq!(claimed.0.name, "x86"); | |
| 1460 | + assert_eq!(claimed.0.state, JobState::Running); | |
| 1461 | + assert_eq!(claimed.1.state, RunState::Running); | |
| 1462 | + assert!(fx.store.claim_job(&linux_only).await.unwrap().is_none()); | |
| 1463 | + | |
| 1464 | + let job_token = claimed.2; | |
| 1465 | + assert!( | |
| 1466 | + fx.store | |
| 1467 | + .job_by_token(&claimed.0.id, &job_token) | |
| 1468 | + .await | |
| 1469 | + .unwrap() | |
| 1470 | + .is_some() | |
| 1471 | + ); | |
| 1472 | + assert!( | |
| 1473 | + fx.store | |
| 1474 | + .job_by_token(&claimed.0.id, "forge_nope") | |
| 1475 | + .await | |
| 1476 | + .unwrap() | |
| 1477 | + .is_none() | |
| 1478 | + ); | |
| 1479 | + | |
| 1480 | + let mut disabled = bare_runner(&["linux", "arm"]); | |
| 1481 | + disabled.disabled = true; | |
| 1482 | + assert!(fx.store.claim_job(&disabled).await.unwrap().is_none()); | |
| 1483 | + | |
| 1484 | + let arm = fx | |
| 1485 | + .store | |
| 1486 | + .claim_job(&bare_runner(&["linux", "arm"])) | |
| 1487 | + .await | |
| 1488 | + .unwrap() | |
| 1489 | + .unwrap(); | |
| 1490 | + assert_eq!(arm.0.name, "arm"); | |
| 1491 | + assert_eq!( | |
| 1492 | + fx.store.run_by_id(&run.id).await.unwrap().unwrap().state, | |
| 1493 | + RunState::Running | |
| 1494 | + ); | |
| 1495 | + } | |
| 1496 | + | |
| 1497 | + #[tokio::test] | |
| 1498 | + async fn two_claims_one_winner() { | |
| 1499 | + let fx = fixture().await; | |
| 1500 | + create( | |
| 1501 | + &fx.store, | |
| 1502 | + &fx.repo.id, | |
| 1503 | + &fx.alice.id, | |
| 1504 | + &[job("only", &[], &[])], | |
| 1505 | + false, | |
| 1506 | + ) | |
| 1507 | + .await; | |
| 1508 | + let (a, b) = (bare_runner(&[]), bare_runner(&[])); | |
| 1509 | + let (ra, rb) = tokio::join!(fx.store.claim_job(&a), fx.store.claim_job(&b)); | |
| 1510 | + let winners = [ra.unwrap(), rb.unwrap()].into_iter().flatten().count(); | |
| 1511 | + assert_eq!(winners, 1); | |
| 1512 | + } | |
| 1513 | + | |
| 1514 | + #[tokio::test] | |
| 1515 | + async fn finish_cascades_and_recomputes_run() { | |
| 1516 | + let fx = fixture().await; | |
| 1517 | + let run = create( | |
| 1518 | + &fx.store, | |
| 1519 | + &fx.repo.id, | |
| 1520 | + &fx.alice.id, | |
| 1521 | + &[ | |
| 1522 | + job("a", &[], &[]), | |
| 1523 | + job("b", &["a"], &[]), | |
| 1524 | + job("c", &["b"], &[]), | |
| 1525 | + job("d", &[], &[]), | |
| 1526 | + ], | |
| 1527 | + false, | |
| 1528 | + ) | |
| 1529 | + .await; | |
| 1530 | + let runner = bare_runner(&[]); | |
| 1531 | + let first = fx.store.claim_job(&runner).await.unwrap().unwrap(); | |
| 1532 | + let second = fx.store.claim_job(&runner).await.unwrap().unwrap(); | |
| 1533 | + let mut names = [first.0.name.clone(), second.0.name.clone()]; | |
| 1534 | + names.sort(); | |
| 1535 | + assert_eq!(names, ["a".to_string(), "d".to_string()]); | |
| 1536 | + let a_id = if first.0.name == "a" { | |
| 1537 | + first.0.id.clone() | |
| 1538 | + } else { | |
| 1539 | + second.0.id.clone() | |
| 1540 | + }; | |
| 1541 | + let d_id = if first.0.name == "d" { | |
| 1542 | + first.0.id.clone() | |
| 1543 | + } else { | |
| 1544 | + second.0.id.clone() | |
| 1545 | + }; | |
| 1546 | + | |
| 1547 | + // Success unblocks b; c still waits on b. | |
| 1548 | + let after_a = fx | |
| 1549 | + .store | |
| 1550 | + .finish_job(&a_id, JobState::Success, Some(0), None) | |
| 1551 | + .await | |
| 1552 | + .unwrap(); | |
| 1553 | + assert_eq!(after_a.state, RunState::Running); | |
| 1554 | + assert_eq!( | |
| 1555 | + job_named(&fx.store, &run.id, "b").await.state, | |
| 1556 | + JobState::Queued | |
| 1557 | + ); | |
| 1558 | + assert_eq!( | |
| 1559 | + job_named(&fx.store, &run.id, "c").await.state, | |
| 1560 | + JobState::Blocked | |
| 1561 | + ); | |
| 1562 | + | |
| 1563 | + // Failure of b skips c transitively once b is done. | |
| 1564 | + let b_claim = fx.store.claim_job(&runner).await.unwrap().unwrap(); | |
| 1565 | + assert_eq!(b_claim.0.name, "b"); | |
| 1566 | + fx.store | |
| 1567 | + .finish_job(&b_claim.0.id, JobState::Failure, Some(1), Some("boom")) | |
| 1568 | + .await | |
| 1569 | + .unwrap(); | |
| 1570 | + let c = job_named(&fx.store, &run.id, "c").await; | |
| 1571 | + assert_eq!(c.state, JobState::Skipped); | |
| 1572 | + assert_eq!( | |
| 1573 | + fx.store.run_by_id(&run.id).await.unwrap().unwrap().state, | |
| 1574 | + RunState::Running, | |
| 1575 | + "d is still running" | |
| 1576 | + ); | |
| 1577 | + | |
| 1578 | + let done = fx | |
| 1579 | + .store | |
| 1580 | + .finish_job(&d_id, JobState::Success, Some(0), None) | |
| 1581 | + .await | |
| 1582 | + .unwrap(); | |
| 1583 | + assert_eq!(done.state, RunState::Failure); | |
| 1584 | + assert!(done.finished_at.is_some()); | |
| 1585 | + assert!(matches!( | |
| 1586 | + fx.store | |
| 1587 | + .finish_job(&d_id, JobState::Success, None, None) | |
| 1588 | + .await, | |
| 1589 | + Err(Error::Conflict(_)) | |
| 1590 | + )); | |
| 1591 | + assert!(matches!( | |
| 1592 | + fx.store | |
| 1593 | + .finish_job(&a_id, JobState::Running, None, None) | |
| 1594 | + .await, | |
| 1595 | + Err(Error::Invalid(_)) | |
| 1596 | + )); | |
| 1597 | + } | |
| 1598 | + | |
| 1599 | + #[tokio::test] | |
| 1600 | + async fn failure_skips_dependants_transitively_and_cancelled_wins_over_success() { | |
| 1601 | + let fx = fixture().await; | |
| 1602 | + let run = create( | |
| 1603 | + &fx.store, | |
| 1604 | + &fx.repo.id, | |
| 1605 | + &fx.alice.id, | |
| 1606 | + &[ | |
| 1607 | + job("x", &[], &[]), | |
| 1608 | + job("y", &["x"], &[]), | |
| 1609 | + job("z", &["y"], &[]), | |
| 1610 | + ], | |
| 1611 | + false, | |
| 1612 | + ) | |
| 1613 | + .await; | |
| 1614 | + let runner = bare_runner(&[]); | |
| 1615 | + let x = fx.store.claim_job(&runner).await.unwrap().unwrap(); | |
| 1616 | + let done = fx | |
| 1617 | + .store | |
| 1618 | + .finish_job(&x.0.id, JobState::Failure, Some(2), None) | |
| 1619 | + .await | |
| 1620 | + .unwrap(); | |
| 1621 | + assert_eq!( | |
| 1622 | + job_named(&fx.store, &run.id, "y").await.state, | |
| 1623 | + JobState::Skipped | |
| 1624 | + ); | |
| 1625 | + assert_eq!( | |
| 1626 | + job_named(&fx.store, &run.id, "z").await.state, | |
| 1627 | + JobState::Skipped | |
| 1628 | + ); | |
| 1629 | + assert_eq!(done.state, RunState::Failure); | |
| 1630 | + } | |
| 1631 | + | |
| 1632 | + #[tokio::test] | |
| 1633 | + async fn cancel_queued_and_running_jobs() { | |
| 1634 | + let fx = fixture().await; | |
| 1635 | + let run = create( | |
| 1636 | + &fx.store, | |
| 1637 | + &fx.repo.id, | |
| 1638 | + &fx.alice.id, | |
| 1639 | + &[job("a", &[], &[]), job("b", &["a"], &[])], | |
| 1640 | + false, | |
| 1641 | + ) | |
| 1642 | + .await; | |
| 1643 | + let runner = bare_runner(&[]); | |
| 1644 | + let a = fx.store.claim_job(&runner).await.unwrap().unwrap(); | |
| 1645 | + fx.store.request_cancel(&run.id).await.unwrap(); | |
| 1646 | + let b = job_named(&fx.store, &run.id, "b").await; | |
| 1647 | + assert_eq!(b.state, JobState::Cancelled); | |
| 1648 | + let a_now = job_named(&fx.store, &run.id, "a").await; | |
| 1649 | + assert_eq!(a_now.state, JobState::Running); | |
| 1650 | + assert!(a_now.cancel_requested); | |
| 1651 | + assert!( | |
| 1652 | + fx.store.heartbeat(&a.0.id).await.unwrap(), | |
| 1653 | + "runner sees the cancel" | |
| 1654 | + ); | |
| 1655 | + assert_eq!( | |
| 1656 | + fx.store.run_by_id(&run.id).await.unwrap().unwrap().state, | |
| 1657 | + RunState::Running | |
| 1658 | + ); | |
| 1659 | + | |
| 1660 | + let done = fx | |
| 1661 | + .store | |
| 1662 | + .finish_job(&a.0.id, JobState::Cancelled, None, None) | |
| 1663 | + .await | |
| 1664 | + .unwrap(); | |
| 1665 | + assert_eq!(done.state, RunState::Cancelled); | |
| 1666 | + assert!(done.finished_at.is_some()); | |
| 1667 | + // A finished run does not change again. | |
| 1668 | + fx.store.request_cancel(&run.id).await.unwrap(); | |
| 1669 | + assert_eq!( | |
| 1670 | + fx.store.run_by_id(&run.id).await.unwrap().unwrap().state, | |
| 1671 | + RunState::Cancelled | |
| 1672 | + ); | |
| 1673 | + assert!(matches!( | |
| 1674 | + fx.store.heartbeat(&a.0.id).await, | |
| 1675 | + Err(Error::NotFound) | |
| 1676 | + )); | |
| 1677 | + } | |
| 1678 | + | |
| 1679 | + #[tokio::test] | |
| 1680 | + async fn rerun_copies_commit_and_specs() { | |
| 1681 | + let fx = fixture().await; | |
| 1682 | + let jobs = [job("build", &[], &["linux"]), job("test", &["build"], &[])]; | |
| 1683 | + let first = create(&fx.store, &fx.repo.id, &fx.alice.id, &jobs, false).await; | |
| 1684 | + let again = fx.store.rerun(&first.id, &fx.alice.id).await.unwrap(); | |
| 1685 | + assert_eq!(again.number, 2); | |
| 1686 | + assert_eq!(again.commit_oid, first.commit_oid); | |
| 1687 | + assert_eq!(again.git_ref, first.git_ref); | |
| 1688 | + assert_eq!(again.trigger, first.trigger); | |
| 1689 | + assert_eq!(again.state, RunState::Queued); | |
| 1690 | + let build = job_named(&fx.store, &again.id, "build").await; | |
| 1691 | + assert_eq!( | |
| 1692 | + build.spec_json, | |
| 1693 | + job_named(&fx.store, &first.id, "build").await.spec_json | |
| 1694 | + ); | |
| 1695 | + assert_eq!(build.state, JobState::Queued); | |
| 1696 | + assert_eq!( | |
| 1697 | + job_named(&fx.store, &again.id, "test").await.state, | |
| 1698 | + JobState::Blocked | |
| 1699 | + ); | |
| 1700 | + assert!(matches!( | |
| 1701 | + fx.store.rerun("missing", &fx.alice.id).await, | |
| 1702 | + Err(Error::NotFound) | |
| 1703 | + )); | |
| 1704 | + } | |
| 1705 | + | |
| 1706 | + #[tokio::test] | |
| 1707 | + async fn reap_lost_jobs_errors_stale_running_jobs() { | |
| 1708 | + let fx = fixture().await; | |
| 1709 | + let run = create( | |
| 1710 | + &fx.store, | |
| 1711 | + &fx.repo.id, | |
| 1712 | + &fx.alice.id, | |
| 1713 | + &[job("a", &[], &[]), job("b", &["a"], &[])], | |
| 1714 | + false, | |
| 1715 | + ) | |
| 1716 | + .await; | |
| 1717 | + let claimed = fx | |
| 1718 | + .store | |
| 1719 | + .claim_job(&bare_runner(&[])) | |
| 1720 | + .await | |
| 1721 | + .unwrap() | |
| 1722 | + .unwrap(); | |
| 1723 | + // Nothing is older than the epoch. | |
| 1724 | + assert!(fx.store.reap_lost_jobs(DAWN).await.unwrap().is_empty()); | |
| 1725 | + let future = stamp_after(Duration::from_secs(3600)); | |
| 1726 | + let reaped = fx.store.reap_lost_jobs(&future).await.unwrap(); | |
| 1727 | + assert_eq!(reaped.len(), 1); | |
| 1728 | + assert_eq!(reaped[0].id, claimed.0.id); | |
| 1729 | + assert_eq!(reaped[0].state, JobState::Error); | |
| 1730 | + assert_eq!(reaped[0].message.as_deref(), Some("runner lost")); | |
| 1731 | + assert_eq!( | |
| 1732 | + job_named(&fx.store, &run.id, "b").await.state, | |
| 1733 | + JobState::Skipped | |
| 1734 | + ); | |
| 1735 | + assert_eq!( | |
| 1736 | + fx.store.run_by_id(&run.id).await.unwrap().unwrap().state, | |
| 1737 | + RunState::Failure | |
| 1738 | + ); | |
| 1739 | + assert!( | |
| 1740 | + fx.store | |
| 1741 | + .job_by_token(&claimed.0.id, &claimed.2) | |
| 1742 | + .await | |
| 1743 | + .unwrap() | |
| 1744 | + .is_none() | |
| 1745 | + ); | |
| 1746 | + } | |
| 1747 | + | |
| 1748 | + #[tokio::test] | |
| 1749 | + async fn purge_old_returns_logs_and_artifacts_once() { | |
| 1750 | + let fx = fixture().await; | |
| 1751 | + let run = create( | |
| 1752 | + &fx.store, | |
| 1753 | + &fx.repo.id, | |
| 1754 | + &fx.alice.id, | |
| 1755 | + &[job("a", &[], &[])], | |
| 1756 | + false, | |
| 1757 | + ) | |
| 1758 | + .await; | |
| 1759 | + let a = job_named(&fx.store, &run.id, "a").await; | |
| 1760 | + fx.store.add_log_bytes(&a.id, 120).await.unwrap(); | |
| 1761 | + fx.store | |
| 1762 | + .add_artifact(&a.id, "dist.tar", 10, "ab") | |
| 1763 | + .await | |
| 1764 | + .unwrap(); | |
| 1765 | + fx.store.request_cancel(&run.id).await.unwrap(); | |
| 1766 | + let later = stamp_after(Duration::from_secs(3600)); | |
| 1767 | + | |
| 1768 | + assert_eq!( | |
| 1769 | + fx.store.purge_old(DAWN, DAWN).await.unwrap(), | |
| 1770 | + (vec![], vec![]) | |
| 1771 | + ); | |
| 1772 | + let (logs, artifacts) = fx.store.purge_old(&later, &later).await.unwrap(); | |
| 1773 | + assert_eq!(logs, vec![a.id.clone()]); | |
| 1774 | + assert_eq!(artifacts.len(), 1); | |
| 1775 | + assert_eq!(job_named(&fx.store, &run.id, "a").await.log_bytes, 0); | |
| 1776 | + assert!( | |
| 1777 | + fx.store | |
| 1778 | + .artifact(&a.id, "dist.tar") | |
| 1779 | + .await | |
| 1780 | + .unwrap() | |
| 1781 | + .is_none() | |
| 1782 | + ); | |
| 1783 | + let (logs2, artifacts2) = fx.store.purge_old(&later, &later).await.unwrap(); | |
| 1784 | + assert!(logs2.is_empty() && artifacts2.is_empty()); | |
| 1785 | + } | |
| 1786 | + | |
| 1787 | + #[tokio::test] | |
| 1788 | + async fn artifacts_are_unique_per_job_and_listed() { | |
| 1789 | + let fx = fixture().await; | |
| 1790 | + let run = create( | |
| 1791 | + &fx.store, | |
| 1792 | + &fx.repo.id, | |
| 1793 | + &fx.alice.id, | |
| 1794 | + &[job("a", &[], &[])], | |
| 1795 | + false, | |
| 1796 | + ) | |
| 1797 | + .await; | |
| 1798 | + let a = job_named(&fx.store, &run.id, "a").await; | |
| 1799 | + let made = fx | |
| 1800 | + .store | |
| 1801 | + .add_artifact(&a.id, "report", 5, "cafe") | |
| 1802 | + .await | |
| 1803 | + .unwrap(); | |
| 1804 | + assert_eq!(made.size, 5); | |
| 1805 | + let dup = fx.store.add_artifact(&a.id, "report", 6, "beef").await; | |
| 1806 | + assert!(matches!(dup, Err(Error::Conflict(_))), "{dup:?}"); | |
| 1807 | + assert_eq!(fx.store.list_artifacts(&a.id).await.unwrap().len(), 1); | |
| 1808 | + assert_eq!( | |
| 1809 | + fx.store | |
| 1810 | + .artifact(&a.id, "report") | |
| 1811 | + .await | |
| 1812 | + .unwrap() | |
| 1813 | + .unwrap() | |
| 1814 | + .sha256, | |
| 1815 | + "cafe" | |
| 1816 | + ); | |
| 1817 | + } | |
| 1818 | + | |
| 1819 | + #[tokio::test] | |
| 1820 | + async fn secrets_seal_round_trip_and_list_never_returns_values() { | |
| 1821 | + let fx = fixture().await; | |
| 1822 | + let plain = "hunter2-very-secret-value"; | |
| 1823 | + fx.store | |
| 1824 | + .set_ci_secret(&fx.repo.id, "DEPLOY_KEY", plain.as_bytes()) | |
| 1825 | + .await | |
| 1826 | + .unwrap(); | |
| 1827 | + fx.store | |
| 1828 | + .set_ci_secret(&fx.repo.id, "TOKEN", b"second") | |
| 1829 | + .await | |
| 1830 | + .unwrap(); | |
| 1831 | + | |
| 1832 | + // Raw storage is sealed, not plaintext. | |
| 1833 | + { | |
| 1834 | + let conn = fx.store.inner.conn.lock().await; | |
| 1835 | + let raw = fetch_opt( | |
| 1836 | + &conn, | |
| 1837 | + "SELECT sealed FROM ci_secrets WHERE repo_id = ?1 AND name = 'DEPLOY_KEY'", | |
| 1838 | + (fx.repo.id.clone(),), | |
| 1839 | + |row| col_blob(row, 0), | |
| 1840 | + ) | |
| 1841 | + .await | |
| 1842 | + .unwrap() | |
| 1843 | + .unwrap(); | |
| 1844 | + assert!(!raw.windows(plain.len()).any(|w| w == plain.as_bytes())); | |
| 1845 | + } | |
| 1846 | + | |
| 1847 | + let names = fx.store.list_ci_secret_names(&fx.repo.id).await.unwrap(); | |
| 1848 | + assert_eq!( | |
| 1849 | + names.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(), | |
| 1850 | + vec!["DEPLOY_KEY", "TOKEN"] | |
| 1851 | + ); | |
| 1852 | + for (_, stamp) in &names { | |
| 1853 | + assert!(!stamp.contains(plain)); | |
| 1854 | + } | |
| 1855 | + | |
| 1856 | + let values = fx | |
| 1857 | + .store | |
| 1858 | + .ci_secret_values( | |
| 1859 | + &fx.repo.id, | |
| 1860 | + &["DEPLOY_KEY".to_string(), "MISSING".to_string()], | |
| 1861 | + ) | |
| 1862 | + .await | |
| 1863 | + .unwrap(); | |
| 1864 | + assert_eq!(values.len(), 1); | |
| 1865 | + assert_eq!(values.get("DEPLOY_KEY").map(String::as_str), Some(plain)); | |
| 1866 | + | |
| 1867 | + // Replacing keeps one row. | |
| 1868 | + fx.store | |
| 1869 | + .set_ci_secret(&fx.repo.id, "DEPLOY_KEY", b"rotated") | |
| 1870 | + .await | |
| 1871 | + .unwrap(); | |
| 1872 | + assert_eq!( | |
| 1873 | + fx.store | |
| 1874 | + .list_ci_secret_names(&fx.repo.id) | |
| 1875 | + .await | |
| 1876 | + .unwrap() | |
| 1877 | + .len(), | |
| 1878 | + 2 | |
| 1879 | + ); | |
| 1880 | + let rotated = fx | |
| 1881 | + .store | |
| 1882 | + .ci_secret_values(&fx.repo.id, &["DEPLOY_KEY".to_string()]) | |
| 1883 | + .await | |
| 1884 | + .unwrap(); | |
| 1885 | + assert_eq!( | |
| 1886 | + rotated.get("DEPLOY_KEY").map(String::as_str), | |
| 1887 | + Some("rotated") | |
| 1888 | + ); | |
| 1889 | + | |
| 1890 | + assert!( | |
| 1891 | + fx.store | |
| 1892 | + .delete_ci_secret(&fx.repo.id, "TOKEN") | |
| 1893 | + .await | |
| 1894 | + .unwrap() | |
| 1895 | + ); | |
| 1896 | + assert!( | |
| 1897 | + !fx.store | |
| 1898 | + .delete_ci_secret(&fx.repo.id, "TOKEN") | |
| 1899 | + .await | |
| 1900 | + .unwrap() | |
| 1901 | + ); | |
| 1902 | + assert!(matches!( | |
| 1903 | + fx.store.set_ci_secret(&fx.repo.id, "lower", b"x").await, | |
| 1904 | + Err(Error::Invalid(_)) | |
| 1905 | + )); | |
| 1906 | + assert!(matches!( | |
| 1907 | + fx.store | |
| 1908 | + .set_ci_secret(&fx.repo.id, "BAD", &[0xff, 0xfe]) | |
| 1909 | + .await, | |
| 1910 | + Err(Error::Invalid(_)) | |
| 1911 | + )); | |
| 1912 | + } | |
| 1913 | + | |
| 1914 | + #[tokio::test] | |
| 1915 | + async fn secrets_need_the_key() { | |
| 1916 | + let dir = tempfile::tempdir().unwrap(); | |
| 1917 | + let store = Store::open(&dir.path().join("forge.db")).await.unwrap(); | |
| 1918 | + let alice = store | |
| 1919 | + .create_user("Alice", None, "hash", false) | |
| 1920 | + .await | |
| 1921 | + .unwrap(); | |
| 1922 | + let repo = store | |
| 1923 | + .create_repo(&alice.id, "proj", "", Visibility::Public, "main") | |
| 1924 | + .await | |
| 1925 | + .unwrap(); | |
| 1926 | + let result = store.set_ci_secret(&repo.id, "KEY", b"x").await; | |
| 1927 | + assert!(matches!(result, Err(Error::Crypto(_))), "{result:?}"); | |
| 1928 | + } | |
| 1929 | + | |
| 1930 | + #[tokio::test] | |
| 1931 | + async fn delete_repo_removes_ci_rows() { | |
| 1932 | + let fx = fixture().await; | |
| 1933 | + let run = create( | |
| 1934 | + &fx.store, | |
| 1935 | + &fx.repo.id, | |
| 1936 | + &fx.alice.id, | |
| 1937 | + &[job("a", &[], &[])], | |
| 1938 | + false, | |
| 1939 | + ) | |
| 1940 | + .await; | |
| 1941 | + let a = job_named(&fx.store, &run.id, "a").await; | |
| 1942 | + fx.store.add_artifact(&a.id, "out", 1, "00").await.unwrap(); | |
| 1943 | + fx.store | |
| 1944 | + .set_ci_secret(&fx.repo.id, "KEY", b"value") | |
| 1945 | + .await | |
| 1946 | + .unwrap(); | |
| 1947 | + | |
| 1948 | + fx.store.delete_repo(&fx.repo.id).await.unwrap(); | |
| 1949 | + | |
| 1950 | + assert!(fx.store.run_by_id(&run.id).await.unwrap().is_none()); | |
| 1951 | + assert!(fx.store.job_by_id(&a.id).await.unwrap().is_none()); | |
| 1952 | + assert!(fx.store.list_artifacts(&a.id).await.unwrap().is_empty()); | |
| 1953 | + assert!( | |
| 1954 | + fx.store | |
| 1955 | + .list_ci_secret_names(&fx.repo.id) | |
| 1956 | + .await | |
| 1957 | + .unwrap() | |
| 1958 | + .is_empty() | |
| 1959 | + ); | |
| 1960 | + let conn = fx.store.inner.conn.lock().await; | |
| 1961 | + let left = fetch_opt( | |
| 1962 | + &conn, | |
| 1963 | + "SELECT (SELECT COUNT(*) FROM ci_jobs) + (SELECT COUNT(*) FROM ci_artifacts)", | |
| 1964 | + (), | |
| 1965 | + |row| col_int(row, 0), | |
| 1966 | + ) | |
| 1967 | + .await | |
| 1968 | + .unwrap() | |
| 1969 | + .unwrap(); | |
| 1970 | + assert_eq!(left, 0); | |
| 1971 | + } | |
| 1972 | + | |
| 1973 | + #[tokio::test] | |
| 1974 | + async fn deleting_a_user_hands_ci_rows_to_ghost() { | |
| 1975 | + let fx = fixture().await; | |
| 1976 | + let bob = fx | |
| 1977 | + .store | |
| 1978 | + .create_user("Bob", None, "hash", false) | |
| 1979 | + .await | |
| 1980 | + .unwrap(); | |
| 1981 | + let run = create( | |
| 1982 | + &fx.store, | |
| 1983 | + &fx.repo.id, | |
| 1984 | + &bob.id, | |
| 1985 | + &[job("a", &[], &[])], | |
| 1986 | + false, | |
| 1987 | + ) | |
| 1988 | + .await; | |
| 1989 | + let token = fx | |
| 1990 | + .store | |
| 1991 | + .create_registration_token(&bob.id, 1) | |
| 1992 | + .await | |
| 1993 | + .unwrap(); | |
| 1994 | + let ghost = fx.store.ensure_ghost_user().await.unwrap(); | |
| 1995 | + | |
| 1996 | + fx.store.delete_user(&bob.id, &ghost.id).await.unwrap(); | |
| 1997 | + | |
| 1998 | + assert_eq!( | |
| 1999 | + fx.store.run_by_id(&run.id).await.unwrap().unwrap().actor_id, | |
| 2000 | + ghost.id |
crates/forge-core/src/store/commit_status.rs| @@ -0,0 +1,529 @@ | ||
| 1 | +//! Commit statuses: one row per (repo, commit, context). Posting again for the same context | |
| 2 | +//! replaces the row, so the latest report wins. `combined_state` folds the rows of one commit | |
| 3 | +//! into the single state shown next to it. | |
| 4 | + | |
| 5 | +use std::collections::HashMap; | |
| 6 | + | |
| 7 | +use turso::{Row, Value}; | |
| 8 | + | |
| 9 | +use super::{Store, col_opt_text, col_text, db_err, fetch_all, fetch_opt, missing_row, new_id}; | |
| 10 | +use crate::error::{Error, Result}; | |
| 11 | +use crate::time::now_stamp; | |
| 12 | + | |
| 13 | +/// Longest accepted context name, in characters. | |
| 14 | +pub const MAX_CONTEXT_CHARS: usize = 255; | |
| 15 | +/// Longest accepted description, in characters. | |
| 16 | +pub const MAX_DESCRIPTION_CHARS: usize = 1024; | |
| 17 | +/// Longest accepted target URL, in bytes. | |
| 18 | +pub const MAX_TARGET_URL_BYTES: usize = 2048; | |
| 19 | +/// Context used when a report does not name one. | |
| 20 | +pub const DEFAULT_CONTEXT: &str = "default"; | |
| 21 | + | |
| 22 | +const STATUS_COLS: &str = "id, repo_id, sha, context, state, description, target_url, creator_id, \ | |
| 23 | + created_at, updated_at"; | |
| 24 | + | |
| 25 | +/// The state a commit status reports. | |
| 26 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 27 | +pub enum StatusState { | |
| 28 | + Pending, | |
| 29 | + Success, | |
| 30 | + Failure, | |
| 31 | + Error, | |
| 32 | +} | |
| 33 | + | |
| 34 | +impl StatusState { | |
| 35 | + pub fn as_str(self) -> &'static str { | |
| 36 | + match self { | |
| 37 | + StatusState::Pending => "pending", | |
| 38 | + StatusState::Success => "success", | |
| 39 | + StatusState::Failure => "failure", | |
| 40 | + StatusState::Error => "error", | |
| 41 | + } | |
| 42 | + } | |
| 43 | + | |
| 44 | + pub fn parse(text: &str) -> Option<StatusState> { | |
| 45 | + match text { | |
| 46 | + "pending" => Some(StatusState::Pending), | |
| 47 | + "success" => Some(StatusState::Success), | |
| 48 | + "failure" => Some(StatusState::Failure), | |
| 49 | + "error" => Some(StatusState::Error), | |
| 50 | + _ => None, | |
| 51 | + } | |
| 52 | + } | |
| 53 | +} | |
| 54 | + | |
| 55 | +/// One status row as stored. | |
| 56 | +#[derive(Debug, Clone, PartialEq, Eq)] | |
| 57 | +pub struct CommitStatus { | |
| 58 | + pub id: String, | |
| 59 | + pub repo_id: String, | |
| 60 | + pub sha: String, | |
| 61 | + pub context: String, | |
| 62 | + pub state: StatusState, | |
| 63 | + pub description: Option<String>, | |
| 64 | + pub target_url: Option<String>, | |
| 65 | + pub creator_id: String, | |
| 66 | + pub created_at: String, | |
| 67 | + pub updated_at: String, | |
| 68 | +} | |
| 69 | + | |
| 70 | +/// Folds the states of one commit's statuses. Any failure or error makes the result failure, else | |
| 71 | +/// any pending makes it pending, else success. `None` when there are no statuses. | |
| 72 | +pub fn combine<I>(states: I) -> Option<StatusState> | |
| 73 | +where | |
| 74 | + I: IntoIterator<Item = StatusState>, | |
| 75 | +{ | |
| 76 | + let mut seen = false; | |
| 77 | + let mut pending = false; | |
| 78 | + for state in states { | |
| 79 | + seen = true; | |
| 80 | + match state { | |
| 81 | + StatusState::Failure | StatusState::Error => return Some(StatusState::Failure), | |
| 82 | + StatusState::Pending => pending = true, | |
| 83 | + StatusState::Success => {} | |
| 84 | + } | |
| 85 | + } | |
| 86 | + match (seen, pending) { | |
| 87 | + (false, _) => None, | |
| 88 | + (true, true) => Some(StatusState::Pending), | |
| 89 | + (true, false) => Some(StatusState::Success), | |
| 90 | + } | |
| 91 | +} | |
| 92 | + | |
| 93 | +/// True for a full object id: 40 (SHA-1) or 64 (SHA-256) lowercase hex characters. | |
| 94 | +pub fn is_full_sha(sha: &str) -> bool { | |
| 95 | + matches!(sha.len(), 40 | 64) && sha.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) | |
| 96 | +} | |
| 97 | + | |
| 98 | +fn validate_context(context: &str) -> Result<String> { | |
| 99 | + let trimmed = context.trim(); | |
| 100 | + if trimmed.is_empty() { | |
| 101 | + return Err(Error::Invalid("context must not be empty".into())); | |
| 102 | + } | |
| 103 | + if trimmed.chars().count() > MAX_CONTEXT_CHARS { | |
| 104 | + return Err(Error::Invalid(format!( | |
| 105 | + "context must be at most {MAX_CONTEXT_CHARS} characters" | |
| 106 | + ))); | |
| 107 | + } | |
| 108 | + if trimmed.chars().any(char::is_control) { | |
| 109 | + return Err(Error::Invalid( | |
| 110 | + "context must not contain control characters".into(), | |
| 111 | + )); | |
| 112 | + } | |
| 113 | + Ok(trimmed.to_string()) | |
| 114 | +} | |
| 115 | + | |
| 116 | +fn validate_description(description: Option<&str>) -> Result<Option<String>> { | |
| 117 | + let Some(text) = description.map(str::trim).filter(|t| !t.is_empty()) else { | |
| 118 | + return Ok(None); | |
| 119 | + }; | |
| 120 | + if text.chars().count() > MAX_DESCRIPTION_CHARS { | |
| 121 | + return Err(Error::Invalid(format!( | |
| 122 | + "description must be at most {MAX_DESCRIPTION_CHARS} characters" | |
| 123 | + ))); | |
| 124 | + } | |
| 125 | + Ok(Some(text.to_string())) | |
| 126 | +} | |
| 127 | + | |
| 128 | +fn validate_target_url(target_url: Option<&str>) -> Result<Option<String>> { | |
| 129 | + let Some(url) = target_url.map(str::trim).filter(|t| !t.is_empty()) else { | |
| 130 | + return Ok(None); | |
| 131 | + }; | |
| 132 | + let lower = url.to_ascii_lowercase(); | |
| 133 | + if !(lower.starts_with("https://") || lower.starts_with("http://")) { | |
| 134 | + return Err(Error::Invalid( | |
| 135 | + "target_url must be an http or https URL".into(), | |
| 136 | + )); | |
| 137 | + } | |
| 138 | + if url.len() > MAX_TARGET_URL_BYTES { | |
| 139 | + return Err(Error::Invalid(format!( | |
| 140 | + "target_url must be at most {MAX_TARGET_URL_BYTES} bytes" | |
| 141 | + ))); | |
| 142 | + } | |
| 143 | + if url.bytes().any(|b| b <= b' ' || b == 0x7f) { | |
| 144 | + return Err(Error::Invalid( | |
| 145 | + "target_url must not contain spaces or control characters".into(), | |
| 146 | + )); | |
| 147 | + } | |
| 148 | + Ok(Some(url.to_string())) | |
| 149 | +} | |
| 150 | + | |
| 151 | +fn opt_value(text: Option<String>) -> Value { | |
| 152 | + text.map_or(Value::Null, Value::Text) | |
| 153 | +} | |
| 154 | + | |
| 155 | +impl Store { | |
| 156 | + /// Records `state` for `context` on the commit. An existing row for the same context is | |
| 157 | + /// replaced, including its creator and description. The commit itself is not checked here; | |
| 158 | + /// callers verify that it exists in the repo. | |
| 159 | + #[allow(clippy::too_many_arguments)] | |
| 160 | + pub async fn set_status( | |
| 161 | + &self, | |
| 162 | + repo_id: &str, | |
| 163 | + sha: &str, | |
| 164 | + context: &str, | |
| 165 | + state: StatusState, | |
| 166 | + description: Option<&str>, | |
| 167 | + target_url: Option<&str>, | |
| 168 | + creator_id: &str, | |
| 169 | + ) -> Result<CommitStatus> { | |
| 170 | + if !is_full_sha(sha) { | |
| 171 | + return Err(Error::Invalid("sha must be a full lowercase hex id".into())); | |
| 172 | + } | |
| 173 | + let context = validate_context(context)?; | |
| 174 | + let description = validate_description(description)?; | |
| 175 | + let target_url = validate_target_url(target_url)?; | |
| 176 | + let now = now_stamp(); | |
| 177 | + | |
| 178 | + let conn = self.inner.conn.lock().await; | |
| 179 | + let params: Vec<Value> = vec![ | |
| 180 | + Value::Text(new_id()), | |
| 181 | + Value::Text(repo_id.to_string()), | |
| 182 | + Value::Text(sha.to_string()), | |
| 183 | + Value::Text(context.clone()), | |
| 184 | + Value::Text(state.as_str().to_string()), | |
| 185 | + opt_value(description), | |
| 186 | + opt_value(target_url), | |
| 187 | + Value::Text(creator_id.to_string()), | |
| 188 | + Value::Text(now.clone()), | |
| 189 | + Value::Text(now), | |
| 190 | + ]; | |
| 191 | + conn.execute( | |
| 192 | + "INSERT INTO commit_statuses (id, repo_id, sha, context, state, description, target_url, \ | |
| 193 | + creator_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) \ | |
| 194 | + ON CONFLICT(repo_id, sha, context) DO UPDATE SET state = excluded.state, \ | |
| 195 | + description = excluded.description, target_url = excluded.target_url, \ | |
| 196 | + creator_id = excluded.creator_id, updated_at = excluded.updated_at", | |
| 197 | + params, | |
| 198 | + ) | |
| 199 | + .await | |
| 200 | + .map_err(db_err)?; | |
| 201 | + | |
| 202 | + fetch_opt( | |
| 203 | + &conn, | |
| 204 | + &format!( | |
| 205 | + "SELECT {STATUS_COLS} FROM commit_statuses WHERE repo_id = ?1 AND sha = ?2 AND context = ?3" | |
| 206 | + ), | |
| 207 | + vec![ | |
| 208 | + Value::Text(repo_id.to_string()), | |
| 209 | + Value::Text(sha.to_string()), | |
| 210 | + Value::Text(context), | |
| 211 | + ], | |
| 212 | + map_status, | |
| 213 | + ) | |
| 214 | + .await? | |
| 215 | + .ok_or_else(missing_row) | |
| 216 | + } | |
| 217 | + | |
| 218 | + /// Every status of one commit, ordered by context. | |
| 219 | + pub async fn statuses_for(&self, repo_id: &str, sha: &str) -> Result<Vec<CommitStatus>> { | |
| 220 | + let conn = self.read_conn().await; | |
| 221 | + fetch_all( | |
| 222 | + &conn, | |
| 223 | + &format!( | |
| 224 | + "SELECT {STATUS_COLS} FROM commit_statuses WHERE repo_id = ?1 AND sha = ?2 \ | |
| 225 | + ORDER BY context" | |
| 226 | + ), | |
| 227 | + (repo_id.to_string(), sha.to_string()), | |
| 228 | + map_status, | |
| 229 | + ) | |
| 230 | + .await | |
| 231 | + } | |
| 232 | + | |
| 233 | + /// The combined state of one commit, or `None` when it has no statuses. | |
| 234 | + pub async fn combined_state(&self, repo_id: &str, sha: &str) -> Result<Option<StatusState>> { | |
| 235 | + let statuses = self.statuses_for(repo_id, sha).await?; | |
| 236 | + Ok(combine(statuses.into_iter().map(|s| s.state))) | |
| 237 | + } | |
| 238 | + | |
| 239 | + /// Combined state for each of `shas` that has at least one status, in one query. Commits | |
| 240 | + /// without statuses are absent from the map. | |
| 241 | + pub async fn statuses_for_many( | |
| 242 | + &self, | |
| 243 | + repo_id: &str, | |
| 244 | + shas: &[&str], | |
| 245 | + ) -> Result<HashMap<String, StatusState>> { | |
| 246 | + let rows = self.statuses_for_many_rows(repo_id, shas).await?; | |
| 247 | + Ok(rows | |
| 248 | + .into_iter() | |
| 249 | + .filter_map(|(sha, statuses)| { | |
| 250 | + combine(statuses.iter().map(|s| s.state)).map(|state| (sha, state)) | |
| 251 | + }) | |
| 252 | + .collect()) | |
| 253 | + } | |
| 254 | + | |
| 255 | + /// Every status row of each of `shas`, grouped by commit, in one query. Commits without | |
| 256 | + /// statuses are absent from the map. Used by the commit list to show each context. | |
| 257 | + pub async fn statuses_for_many_rows( | |
| 258 | + &self, | |
| 259 | + repo_id: &str, | |
| 260 | + shas: &[&str], | |
| 261 | + ) -> Result<HashMap<String, Vec<CommitStatus>>> { | |
| 262 | + if shas.is_empty() { | |
| 263 | + return Ok(HashMap::new()); | |
| 264 | + } | |
| 265 | + let placeholders: Vec<String> = (2..=shas.len() + 1).map(|i| format!("?{i}")).collect(); | |
| 266 | + let sql = format!( | |
| 267 | + "SELECT {STATUS_COLS} FROM commit_statuses WHERE repo_id = ?1 AND sha IN ({}) \ | |
| 268 | + ORDER BY sha, context", | |
| 269 | + placeholders.join(", ") | |
| 270 | + ); | |
| 271 | + let mut params: Vec<Value> = Vec::with_capacity(shas.len() + 1); | |
| 272 | + params.push(Value::Text(repo_id.to_string())); | |
| 273 | + params.extend(shas.iter().map(|sha| Value::Text((*sha).to_string()))); | |
| 274 | + | |
| 275 | + let conn = self.read_conn().await; | |
| 276 | + let rows = fetch_all(&conn, &sql, params, map_status).await?; | |
| 277 | + let mut grouped: HashMap<String, Vec<CommitStatus>> = HashMap::new(); | |
| 278 | + for row in rows { | |
| 279 | + grouped.entry(row.sha.clone()).or_default().push(row); | |
| 280 | + } | |
| 281 | + Ok(grouped) | |
| 282 | + } | |
| 283 | +} | |
| 284 | + | |
| 285 | +fn parse_state(text: &str) -> Result<StatusState> { | |
| 286 | + StatusState::parse(text) | |
| 287 | + .ok_or_else(|| Error::Database(format!("unknown commit status state: {text}"))) | |
| 288 | +} | |
| 289 | + | |
| 290 | +fn map_status(row: &Row) -> Result<CommitStatus> { | |
| 291 | + let state = parse_state(&col_text(row, 4)?)?; | |
| 292 | + Ok(CommitStatus { | |
| 293 | + id: col_text(row, 0)?, | |
| 294 | + repo_id: col_text(row, 1)?, | |
| 295 | + sha: col_text(row, 2)?, | |
| 296 | + context: col_text(row, 3)?, | |
| 297 | + state, | |
| 298 | + description: col_opt_text(row, 5)?, | |
| 299 | + target_url: col_opt_text(row, 6)?, | |
| 300 | + creator_id: col_text(row, 7)?, | |
| 301 | + created_at: col_text(row, 8)?, | |
| 302 | + updated_at: col_text(row, 9)?, | |
| 303 | + }) | |
| 304 | +} | |
| 305 | + | |
| 306 | +#[cfg(test)] | |
| 307 | +mod tests { | |
| 308 | + use super::*; | |
| 309 | + | |
| 310 | + #[test] | |
| 311 | + fn combine_prefers_failure_then_pending() { | |
| 312 | + use StatusState::*; | |
| 313 | + assert_eq!(combine([]), None); | |
| 314 | + assert_eq!(combine([Success, Success]), Some(Success)); | |
| 315 | + assert_eq!(combine([Success, Pending]), Some(Pending)); | |
| 316 | + assert_eq!(combine([Pending, Failure, Success]), Some(Failure)); | |
| 317 | + assert_eq!(combine([Error, Pending]), Some(Failure)); | |
| 318 | + } | |
| 319 | + | |
| 320 | + #[test] | |
| 321 | + fn full_sha_is_40_or_64_lowercase_hex() { | |
| 322 | + assert!(is_full_sha(&"a".repeat(40))); | |
| 323 | + assert!(is_full_sha(&"0".repeat(64))); | |
| 324 | + assert!(!is_full_sha(&"a".repeat(39))); | |
| 325 | + assert!(!is_full_sha(&"A".repeat(40))); | |
| 326 | + assert!(!is_full_sha(&"g".repeat(40))); | |
| 327 | + } | |
| 328 | + | |
| 329 | + #[test] | |
| 330 | + fn target_url_must_be_http_or_https() { | |
| 331 | + assert!(validate_target_url(Some("https://ci.example/run/1")).is_ok()); | |
| 332 | + assert!(validate_target_url(Some("HTTP://ci.example")).is_ok()); | |
| 333 | + assert!(validate_target_url(Some("javascript:alert(1)")).is_err()); | |
| 334 | + assert!(validate_target_url(Some("https://ci.example/a b")).is_err()); | |
| 335 | + assert!(validate_target_url(None).is_ok_and(|v| v.is_none())); | |
| 336 | + } | |
| 337 | + | |
| 338 | + #[test] | |
| 339 | + fn context_and_description_limits() { | |
| 340 | + assert!(validate_context(" ").is_err()); | |
| 341 | + assert!(validate_context(&"c".repeat(MAX_CONTEXT_CHARS + 1)).is_err()); | |
| 342 | + assert!(validate_context("ci/build").is_ok()); | |
| 343 | + assert!(validate_description(Some(&"d".repeat(MAX_DESCRIPTION_CHARS + 1))).is_err()); | |
| 344 | + } | |
| 345 | + | |
| 346 | + use crate::model::Visibility; | |
| 347 | + | |
| 348 | + const SHA_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; | |
| 349 | + const SHA_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; | |
| 350 | + | |
| 351 | + async fn fixture() -> (tempfile::TempDir, Store, String, String) { | |
| 352 | + let dir = tempfile::tempdir().expect("tempdir"); | |
| 353 | + let store = Store::open(&dir.path().join("forge.db")) | |
| 354 | + .await | |
| 355 | + .expect("store"); | |
| 356 | + let owner = store | |
| 357 | + .create_user("alice", None, "not-a-real-hash", false) | |
| 358 | + .await | |
| 359 | + .expect("user"); | |
| 360 | + let repo = store | |
| 361 | + .create_repo(&owner.id, "demo", "", Visibility::Public, "main") | |
| 362 | + .await | |
| 363 | + .expect("repo"); | |
| 364 | + (dir, store, repo.id, owner.id) | |
| 365 | + } | |
| 366 | + | |
| 367 | + #[tokio::test] | |
| 368 | + async fn upsert_keeps_one_row_per_context_and_combines() { | |
| 369 | + let (_dir, store, repo_id, user_id) = fixture().await; | |
| 370 | + let set = |context: &'static str, state: StatusState| { | |
| 371 | + let store = store.clone(); | |
| 372 | + let repo_id = repo_id.clone(); | |
| 373 | + let user_id = user_id.clone(); | |
| 374 | + async move { | |
| 375 | + store | |
| 376 | + .set_status(&repo_id, SHA_A, context, state, None, None, &user_id) | |
| 377 | + .await | |
| 378 | + .expect("set") | |
| 379 | + } | |
| 380 | + }; | |
| 381 | + set("a", StatusState::Pending).await; | |
| 382 | + set("b", StatusState::Success).await; | |
| 383 | + assert_eq!( | |
| 384 | + store.combined_state(&repo_id, SHA_A).await.unwrap(), | |
| 385 | + Some(StatusState::Pending) | |
| 386 | + ); | |
| 387 | + | |
| 388 | + set("a", StatusState::Failure).await; | |
| 389 | + assert_eq!( | |
| 390 | + store.combined_state(&repo_id, SHA_A).await.unwrap(), | |
| 391 | + Some(StatusState::Failure) | |
| 392 | + ); | |
| 393 | + | |
| 394 | + // The latest report for "a" replaces the failure. | |
| 395 | + let replaced = set("a", StatusState::Success).await; | |
| 396 | + assert_eq!(replaced.state, StatusState::Success); | |
| 397 | + assert_eq!( | |
| 398 | + store.combined_state(&repo_id, SHA_A).await.unwrap(), | |
| 399 | + Some(StatusState::Success) | |
| 400 | + ); | |
| 401 | + let rows = store.statuses_for(&repo_id, SHA_A).await.unwrap(); | |
| 402 | + assert_eq!( | |
| 403 | + rows.iter().map(|r| r.context.as_str()).collect::<Vec<_>>(), | |
| 404 | + ["a", "b"] | |
| 405 | + ); | |
| 406 | + assert_eq!(store.combined_state(&repo_id, SHA_B).await.unwrap(), None); | |
| 407 | + } | |
| 408 | + | |
| 409 | + #[tokio::test] | |
| 410 | + async fn many_lookup_skips_commits_without_statuses() { | |
| 411 | + let (_dir, store, repo_id, user_id) = fixture().await; | |
| 412 | + store | |
| 413 | + .set_status( | |
| 414 | + &repo_id, | |
| 415 | + SHA_A, | |
| 416 | + "ci", | |
| 417 | + StatusState::Error, | |
| 418 | + Some("boom"), | |
| 419 | + None, | |
| 420 | + &user_id, | |
| 421 | + ) | |
| 422 | + .await | |
| 423 | + .expect("set"); | |
| 424 | + let found = store | |
| 425 | + .statuses_for_many(&repo_id, &[SHA_A, SHA_B]) | |
| 426 | + .await | |
| 427 | + .unwrap(); | |
| 428 | + assert_eq!(found.len(), 1); | |
| 429 | + assert_eq!(found.get(SHA_A), Some(&StatusState::Failure)); | |
| 430 | + assert!( | |
| 431 | + store | |
| 432 | + .statuses_for_many(&repo_id, &[]) | |
| 433 | + .await | |
| 434 | + .unwrap() | |
| 435 | + .is_empty() | |
| 436 | + ); | |
| 437 | + } | |
| 438 | + | |
| 439 | + #[tokio::test] | |
| 440 | + async fn invalid_input_is_refused_and_stores_nothing() { | |
| 441 | + let (_dir, store, repo_id, user_id) = fixture().await; | |
| 442 | + let bad_sha = store | |
| 443 | + .set_status( | |
| 444 | + &repo_id, | |
| 445 | + "ABC", | |
| 446 | + "ci", | |
| 447 | + StatusState::Success, | |
| 448 | + None, | |
| 449 | + None, | |
| 450 | + &user_id, | |
| 451 | + ) | |
| 452 | + .await; | |
| 453 | + assert!(matches!(bad_sha, Err(Error::Invalid(_)))); | |
| 454 | + let bad_url = store | |
| 455 | + .set_status( | |
| 456 | + &repo_id, | |
| 457 | + SHA_A, | |
| 458 | + "ci", | |
| 459 | + StatusState::Success, | |
| 460 | + None, | |
| 461 | + Some("ftp://x"), | |
| 462 | + &user_id, | |
| 463 | + ) | |
| 464 | + .await; | |
| 465 | + assert!(matches!(bad_url, Err(Error::Invalid(_)))); | |
| 466 | + assert!( | |
| 467 | + store | |
| 468 | + .statuses_for(&repo_id, SHA_A) | |
| 469 | + .await | |
| 470 | + .unwrap() | |
| 471 | + .is_empty() | |
| 472 | + ); | |
| 473 | + } | |
| 474 | + | |
| 475 | + #[tokio::test] | |
| 476 | + async fn deleting_the_repo_removes_its_statuses() { | |
| 477 | + let (_dir, store, repo_id, user_id) = fixture().await; | |
| 478 | + store | |
| 479 | + .set_status( | |
| 480 | + &repo_id, | |
| 481 | + SHA_A, | |
| 482 | + "ci", | |
| 483 | + StatusState::Success, | |
| 484 | + None, | |
| 485 | + None, | |
| 486 | + &user_id, | |
| 487 | + ) | |
| 488 | + .await | |
| 489 | + .expect("set"); | |
| 490 | + store.delete_repo(&repo_id).await.expect("delete"); | |
| 491 | + assert!( | |
| 492 | + store | |
| 493 | + .statuses_for(&repo_id, SHA_A) | |
| 494 | + .await | |
| 495 | + .unwrap() | |
| 496 | + .is_empty() | |
| 497 | + ); | |
| 498 | + } | |
| 499 | + | |
| 500 | + #[tokio::test] | |
| 501 | + async fn deleting_a_user_reassigns_their_statuses_to_ghost() { | |
| 502 | + let (_dir, store, repo_id, _owner_id) = fixture().await; | |
| 503 | + let reporter = store | |
| 504 | + .create_user("ci-bot", None, "not-a-real-hash", false) | |
| 505 | + .await | |
| 506 | + .expect("user"); | |
| 507 | + store | |
| 508 | + .set_status( | |
| 509 | + &repo_id, | |
| 510 | + SHA_A, | |
| 511 | + "ci", | |
| 512 | + StatusState::Failure, | |
| 513 | + None, | |
| 514 | + None, | |
| 515 | + &reporter.id, | |
| 516 | + ) | |
| 517 | + .await | |
| 518 | + .expect("set"); | |
| 519 | + let ghost = store.ensure_ghost_user().await.expect("ghost"); | |
| 520 | + store | |
| 521 | + .delete_user(&reporter.id, &ghost.id) | |
| 522 | + .await | |
| 523 | + .expect("delete user"); | |
| 524 | + let rows = store.statuses_for(&repo_id, SHA_A).await.unwrap(); | |
| 525 | + assert_eq!(rows.len(), 1); | |
| 526 | + assert_eq!(rows[0].creator_id, ghost.id); | |
| 527 | + assert_eq!(rows[0].state, StatusState::Failure); | |
| 528 | + } | |
| 529 | +} |
crates/forge-core/src/store/contributions.rs| @@ -0,0 +1,146 @@ | ||
| 1 | +//! Daily contribution counts for one user, for the dashboard heatmap. | |
| 2 | + | |
| 3 | +use turso::Value; | |
| 4 | + | |
| 5 | +use super::push_recent::{ZERO_OID_SHA1, ZERO_OID_SHA256}; | |
| 6 | +use super::{Store, col_int, col_text, fetch_all}; | |
| 7 | +use crate::error::Result; | |
| 8 | + | |
| 9 | +impl Store { | |
| 10 | + /// Contributions per UTC day by `user_id` at or after `since` (an RFC 3339 stamp), as | |
| 11 | + /// `(YYYY-MM-DD, count)` sorted by day ascending; days with no activity are left out. | |
| 12 | + /// A contribution is a push (ref deletions are left out), an issue or pull request opened | |
| 13 | + /// (pull requests are `issues` rows with `kind = 'pull'`), or a comment. | |
| 14 | + pub async fn contribution_counts( | |
| 15 | + &self, | |
| 16 | + user_id: &str, | |
| 17 | + since: &str, | |
| 18 | + ) -> Result<Vec<(String, u32)>> { | |
| 19 | + // Parameters: ?1 user, ?2 since, ?3 and ?4 zero oids. | |
| 20 | + let sql = "SELECT day, COUNT(*) FROM ( \ | |
| 21 | + SELECT substr(created_at, 1, 10) AS day FROM push_events \ | |
| 22 | + WHERE actor_id = ?1 AND created_at >= ?2 AND new_oid NOT IN (?3, ?4) \ | |
| 23 | + UNION ALL \ | |
| 24 | + SELECT substr(created_at, 1, 10) FROM issues \ | |
| 25 | + WHERE author_id = ?1 AND created_at >= ?2 AND kind IN ('issue', 'pull') \ | |
| 26 | + UNION ALL \ | |
| 27 | + SELECT substr(created_at, 1, 10) FROM comments \ | |
| 28 | + WHERE author_id = ?1 AND created_at >= ?2 \ | |
| 29 | + ) GROUP BY day ORDER BY day"; | |
| 30 | + let params = vec![ | |
| 31 | + Value::Text(user_id.to_string()), | |
| 32 | + Value::Text(since.to_string()), | |
| 33 | + Value::Text(ZERO_OID_SHA1.to_string()), | |
| 34 | + Value::Text(ZERO_OID_SHA256.to_string()), | |
| 35 | + ]; | |
| 36 | + | |
| 37 | + let conn = self.read_conn().await; | |
| 38 | + let rows = fetch_all(&conn, sql, params, |row| { | |
| 39 | + Ok((col_text(row, 0)?, col_int(row, 1)?)) | |
| 40 | + }) | |
| 41 | + .await?; | |
| 42 | + Ok(rows | |
| 43 | + .into_iter() | |
| 44 | + .map(|(day, count)| (day, u32::try_from(count).unwrap_or(u32::MAX))) | |
| 45 | + .collect()) | |
| 46 | + } | |
| 47 | +} | |
| 48 | + | |
| 49 | +#[cfg(test)] | |
| 50 | +mod tests { | |
| 51 | + use crate::model::Visibility; | |
| 52 | + | |
| 53 | + use super::*; | |
| 54 | + | |
| 55 | + const OID: &str = "1111111111111111111111111111111111111111"; | |
| 56 | + const ZERO: &str = ZERO_OID_SHA1; | |
| 57 | + | |
| 58 | + /// Backdates a row's `created_at`. Issues and comments are stamped "now" by the store. | |
| 59 | + async fn set_created_at(store: &Store, table: &str, id: &str, at: &str) { | |
| 60 | + let conn = store.inner.conn.lock().await; | |
| 61 | + conn.execute( | |
| 62 | + &format!("UPDATE {table} SET created_at = ?1 WHERE id = ?2"), | |
| 63 | + (at.to_string(), id.to_string()), | |
| 64 | + ) | |
| 65 | + .await | |
| 66 | + .unwrap(); | |
| 67 | + } | |
| 68 | + | |
| 69 | + #[tokio::test] | |
| 70 | + async fn counts_pushes_issues_pulls_and_comments_per_day() { | |
| 71 | + let dir = tempfile::tempdir().unwrap(); | |
| 72 | + let store = Store::open(&dir.path().join("forge.db")).await.unwrap(); | |
| 73 | + let alice = store.create_user("alice", None, "x", false).await.unwrap(); | |
| 74 | + let bob = store.create_user("bob", None, "x", false).await.unwrap(); | |
| 75 | + let demo = store | |
| 76 | + .create_repo(&alice.id, "demo", "", Visibility::Public, "main") | |
| 77 | + .await | |
| 78 | + .unwrap(); | |
| 79 | + | |
| 80 | + // Day one: a push and an issue. | |
| 81 | + let day1 = "2026-03-01T10:00:00.000000000Z"; | |
| 82 | + let day2 = "2026-03-02T10:00:00.000000000Z"; | |
| 83 | + let before = "2026-02-28T10:00:00.000000000Z"; | |
| 84 | + store | |
| 85 | + .record_push_event_at(&demo.id, &alice.id, "refs/heads/main", ZERO, OID, day1) | |
| 86 | + .await | |
| 87 | + .unwrap(); | |
| 88 | + let issue = store | |
| 89 | + .create_issue(&demo.id, &alice.id, "first", "") | |
| 90 | + .await | |
| 91 | + .unwrap(); | |
| 92 | + set_created_at(&store, "issues", &issue.id, day1).await; | |
| 93 | + | |
| 94 | + // Day two: a branch update, a deletion (not counted), a pull request, a comment. | |
| 95 | + store | |
| 96 | + .record_push_event_at(&demo.id, &alice.id, "refs/heads/main", OID, OID, day2) | |
| 97 | + .await | |
| 98 | + .unwrap(); | |
| 99 | + store | |
| 100 | + .record_push_event_at(&demo.id, &alice.id, "refs/heads/gone", OID, ZERO, day2) | |
| 101 | + .await | |
| 102 | + .unwrap(); | |
| 103 | + let pull = store | |
| 104 | + .create_pull( | |
| 105 | + &demo.id, &alice.id, "change", "", "main", &demo.id, "topic", OID, OID, | |
| 106 | + ) | |
| 107 | + .await | |
| 108 | + .unwrap(); | |
| 109 | + set_created_at(&store, "issues", &pull.issue.id, day2).await; | |
| 110 | + let comment = store.add_comment(&issue.id, &alice.id, "hi").await.unwrap(); | |
| 111 | + set_created_at(&store, "comments", &comment.id, day2).await; | |
| 112 | + | |
| 113 | + // Before `since`, and someone else's activity: left out. | |
| 114 | + store | |
| 115 | + .record_push_event_at(&demo.id, &alice.id, "refs/heads/main", OID, OID, before) | |
| 116 | + .await | |
| 117 | + .unwrap(); | |
| 118 | + store | |
| 119 | + .record_push_event_at(&demo.id, &bob.id, "refs/heads/bobs", ZERO, OID, day2) | |
| 120 | + .await | |
| 121 | + .unwrap(); | |
| 122 | + | |
| 123 | + let since = "2026-03-01T00:00:00.000000000Z"; | |
| 124 | + let counts = store.contribution_counts(&alice.id, since).await.unwrap(); | |
| 125 | + assert_eq!( | |
| 126 | + counts, | |
| 127 | + vec![("2026-03-01".to_string(), 2), ("2026-03-02".to_string(), 3)] | |
| 128 | + ); | |
| 129 | + | |
| 130 | + // `since` filters out the first day. | |
| 131 | + let later = store | |
| 132 | + .contribution_counts(&alice.id, "2026-03-02T00:00:00.000000000Z") | |
| 133 | + .await | |
| 134 | + .unwrap(); | |
| 135 | + assert_eq!(later, vec![("2026-03-02".to_string(), 3)]); | |
| 136 | + | |
| 137 | + // A user with nothing in range gets an empty list. | |
| 138 | + assert!( | |
| 139 | + store | |
| 140 | + .contribution_counts(&bob.id, "2026-03-03T00:00:00.000000000Z") | |
| 141 | + .await | |
| 142 | + .unwrap() | |
| 143 | + .is_empty() | |
| 144 | + ); | |
| 145 | + } | |
| 146 | +} |
crates/forge-core/src/store/discussions.rs| @@ -0,0 +1,1226 @@ | ||
| 1 | +//! Discussions and their comments. Numbers come from `repos.next_issue_number`, the counter that | |
| 2 | +//! issues and pull requests share, in the same transaction as the insert. Comments nest one level. | |
| 3 | +//! Locking, author checks and archive checks are the pages' job, since they depend on the viewer. | |
| 4 | + | |
| 5 | +use turso::transaction::Transaction; | |
| 6 | +use turso::{Connection, Row, Value}; | |
| 7 | + | |
| 8 | +use super::issues::finish; | |
| 9 | +use super::{ | |
| 10 | + Store, col_int, col_opt_text, col_text, db_err, fetch_all, fetch_opt, insert_err, new_id, | |
| 11 | +}; | |
| 12 | +use crate::discussions::{ | |
| 13 | + Discussion, DiscussionCategory, DiscussionComment, DiscussionFilter, DiscussionListItem, | |
| 14 | + DiscussionNotification, DiscussionReason, validate_comment, validate_discussion, | |
| 15 | +}; | |
| 16 | +use crate::error::{Error, Result}; | |
| 17 | +use crate::time::now_stamp; | |
| 18 | + | |
| 19 | +const DISCUSSION_COLS: &str = "d.id, d.repo_id, d.number, d.category, d.title, d.body, \ | |
| 20 | + d.author_id, d.answer_comment_id, d.locked, d.comment_count, d.created_at, d.updated_at"; | |
| 21 | +const COMMENT_COLS: &str = "c.id, c.discussion_id, c.parent_id, c.author_id, c.body, \ | |
| 22 | + c.created_at, c.updated_at, c.deleted_at"; | |
| 23 | + | |
| 24 | +fn map_discussion(row: &Row) -> Result<Discussion> { | |
| 25 | + let category = col_text(row, 3)?; | |
| 26 | + Ok(Discussion { | |
| 27 | + id: col_text(row, 0)?, | |
| 28 | + repo_id: col_text(row, 1)?, | |
| 29 | + number: col_int(row, 2)?, | |
| 30 | + category: DiscussionCategory::parse(&category) | |
| 31 | + .ok_or_else(|| Error::Database(format!("stored category {category:?}")))?, | |
| 32 | + title: col_text(row, 4)?, | |
| 33 | + body: col_text(row, 5)?, | |
| 34 | + author_id: col_text(row, 6)?, | |
| 35 | + answer_comment_id: col_opt_text(row, 7)?, | |
| 36 | + locked: col_int(row, 8)? != 0, | |
| 37 | + comment_count: col_int(row, 9)?, | |
| 38 | + created_at: col_text(row, 10)?, | |
| 39 | + updated_at: col_text(row, 11)?, | |
| 40 | + }) | |
| 41 | +} | |
| 42 | + | |
| 43 | +/// Inbox rows of discussions joined with the discussion, its repo, owner and last actor. `?` | |
| 44 | +/// placeholders are appended by the callers. | |
| 45 | +const DISCUSSION_NOTE_SELECT: &str = "SELECT n.discussion_id, n.repo_id, o.username, r.name, \ | |
| 46 | + d.number, d.title, n.reason, a.username, n.unread, n.updated_at FROM discussion_notifications n \ | |
| 47 | + JOIN discussions d ON d.id = n.discussion_id \ | |
| 48 | + JOIN repos r ON r.id = n.repo_id \ | |
| 49 | + JOIN users o ON o.id = r.owner_id \ | |
| 50 | + LEFT JOIN users a ON a.id = n.last_actor_id"; | |
| 51 | + | |
| 52 | +fn map_discussion_note(row: &Row) -> Result<DiscussionNotification> { | |
| 53 | + let reason = col_text(row, 6)?; | |
| 54 | + Ok(DiscussionNotification { | |
| 55 | + discussion_id: col_text(row, 0)?, | |
| 56 | + repo_id: col_text(row, 1)?, | |
| 57 | + repo_owner: col_text(row, 2)?, | |
| 58 | + repo_name: col_text(row, 3)?, | |
| 59 | + number: col_int(row, 4)?, | |
| 60 | + title: col_text(row, 5)?, | |
| 61 | + reason: DiscussionReason::parse(&reason) | |
| 62 | + .ok_or_else(|| Error::Database(format!("unknown discussion reason {reason:?}")))?, | |
| 63 | + last_actor_name: col_opt_text(row, 7)?, | |
| 64 | + unread: col_int(row, 8)? != 0, | |
| 65 | + updated_at: col_text(row, 9)?, | |
| 66 | + }) | |
| 67 | +} | |
| 68 | + | |
| 69 | +fn map_comment(row: &Row) -> Result<DiscussionComment> { | |
| 70 | + Ok(DiscussionComment { | |
| 71 | + id: col_text(row, 0)?, | |
| 72 | + discussion_id: col_text(row, 1)?, | |
| 73 | + parent_id: col_opt_text(row, 2)?, | |
| 74 | + author_id: col_text(row, 3)?, | |
| 75 | + body: col_text(row, 4)?, | |
| 76 | + created_at: col_text(row, 5)?, | |
| 77 | + updated_at: col_text(row, 6)?, | |
| 78 | + deleted: col_opt_text(row, 7)?.is_some(), | |
| 79 | + }) | |
| 80 | +} | |
| 81 | + | |
| 82 | +/// The discussion with this id, or `NotFound`. | |
| 83 | +async fn discussion_row(conn: &Connection, discussion_id: &str) -> Result<Discussion> { | |
| 84 | + fetch_opt( | |
| 85 | + conn, | |
| 86 | + &format!("SELECT {DISCUSSION_COLS} FROM discussions d WHERE d.id = ?1 LIMIT 1"), | |
| 87 | + (discussion_id.to_string(),), | |
| 88 | + map_discussion, | |
| 89 | + ) | |
| 90 | + .await? | |
| 91 | + .ok_or(Error::NotFound) | |
| 92 | +} | |
| 93 | + | |
| 94 | +/// Removes the discussion rows of a repo, with their comments, reactions and inbox rows. Called | |
| 95 | +/// from `Store::delete_repo`, children first. | |
| 96 | +pub(super) async fn delete_repo_discussion_rows(tx: &Transaction<'_>, repo_id: &str) -> Result<()> { | |
| 97 | + let id = repo_id.to_string(); | |
| 98 | + for sql in [ | |
| 99 | + "DELETE FROM reactions WHERE target_kind = 'discussion_comment' AND target_id IN \ | |
| 100 | + (SELECT c.id FROM discussion_comments c JOIN discussions d ON d.id = c.discussion_id \ | |
| 101 | + WHERE d.repo_id = ?1)", | |
| 102 | + "DELETE FROM reactions WHERE target_kind = 'discussion' AND target_id IN \ | |
| 103 | + (SELECT id FROM discussions WHERE repo_id = ?1)", | |
| 104 | + "DELETE FROM discussion_notifications WHERE repo_id = ?1", | |
| 105 | + "DELETE FROM discussion_comments WHERE discussion_id IN \ | |
| 106 | + (SELECT id FROM discussions WHERE repo_id = ?1)", | |
| 107 | + "DELETE FROM discussions WHERE repo_id = ?1", | |
| 108 | + ] { | |
| 109 | + tx.execute(sql, (id.clone(),)).await.map_err(db_err)?; | |
| 110 | + } | |
| 111 | + Ok(()) | |
| 112 | +} | |
| 113 | + | |
| 114 | +impl Store { | |
| 115 | + /// Whether the repo has discussions turned on. Off by default. | |
| 116 | + pub async fn discussions_enabled(&self, repo_id: &str) -> Result<bool> { | |
| 117 | + let conn = self.read_conn().await; | |
| 118 | + let flag = fetch_opt( | |
| 119 | + &conn, | |
| 120 | + "SELECT discussions_enabled FROM repos WHERE id = ?1 LIMIT 1", | |
| 121 | + (repo_id.to_string(),), | |
| 122 | + |row| col_int(row, 0), | |
| 123 | + ) | |
| 124 | + .await? | |
| 125 | + .ok_or(Error::NotFound)?; | |
| 126 | + Ok(flag != 0) | |
| 127 | + } | |
| 128 | + | |
| 129 | + /// Turns discussions on or off for the repo. Existing discussions are kept while it is off. | |
| 130 | + pub async fn set_discussions_enabled(&self, repo_id: &str, enabled: bool) -> Result<()> { | |
| 131 | + let conn = self.inner.conn.lock().await; | |
| 132 | + let changed = conn | |
| 133 | + .execute( | |
| 134 | + "UPDATE repos SET discussions_enabled = ?2 WHERE id = ?1", | |
| 135 | + (repo_id.to_string(), i64::from(enabled)), | |
| 136 | + ) | |
| 137 | + .await | |
| 138 | + .map_err(db_err)?; | |
| 139 | + if changed == 0 { | |
| 140 | + return Err(Error::NotFound); | |
| 141 | + } | |
| 142 | + Ok(()) | |
| 143 | + } | |
| 144 | + | |
| 145 | + /// Creates a discussion with the repo's next number. Title and body are validated. | |
| 146 | + pub async fn create_discussion( | |
| 147 | + &self, | |
| 148 | + repo_id: &str, | |
| 149 | + author_id: &str, | |
| 150 | + category: DiscussionCategory, | |
| 151 | + title: &str, | |
| 152 | + body: &str, | |
| 153 | + ) -> Result<Discussion> { | |
| 154 | + let title = validate_discussion(title, body)?; | |
| 155 | + let conn = self.inner.conn.lock().await; | |
| 156 | + let tx = conn.unchecked_transaction().await.map_err(db_err)?; | |
| 157 | + let outcome: Result<Discussion> = async { | |
| 158 | + let number = fetch_opt( | |
| 159 | + &tx, | |
| 160 | + "SELECT next_issue_number FROM repos WHERE id = ?1 LIMIT 1", | |
| 161 | + (repo_id.to_string(),), | |
| 162 | + |row| col_int(row, 0), | |
| 163 | + ) | |
| 164 | + .await? | |
| 165 | + .ok_or(Error::NotFound)?; | |
| 166 | + let now = now_stamp(); | |
| 167 | + let id = new_id(); | |
| 168 | + tx.execute( | |
| 169 | + "INSERT INTO discussions (id, repo_id, number, category, title, body, author_id, \ | |
| 170 | + locked, comment_count, created_at, updated_at) \ | |
| 171 | + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 0, 0, ?8, ?8)", | |
| 172 | + ( | |
| 173 | + id.clone(), | |
| 174 | + repo_id.to_string(), | |
| 175 | + number, | |
| 176 | + category.as_str().to_string(), | |
| 177 | + title.clone(), | |
| 178 | + body.to_string(), | |
| 179 | + author_id.to_string(), | |
| 180 | + now.clone(), | |
| 181 | + ), | |
| 182 | + ) | |
| 183 | + .await | |
| 184 | + .map_err(|e| insert_err(e, "discussion number already taken"))?; | |
| 185 | + tx.execute( | |
| 186 | + "UPDATE repos SET next_issue_number = next_issue_number + 1 WHERE id = ?1", | |
| 187 | + (repo_id.to_string(),), | |
| 188 | + ) | |
| 189 | + .await | |
| 190 | + .map_err(db_err)?; | |
| 191 | + Ok(Discussion { | |
| 192 | + id, | |
| 193 | + repo_id: repo_id.to_string(), | |
| 194 | + number, | |
| 195 | + category, | |
| 196 | + title, | |
| 197 | + body: body.to_string(), | |
| 198 | + author_id: author_id.to_string(), | |
| 199 | + answer_comment_id: None, | |
| 200 | + locked: false, | |
| 201 | + comment_count: 0, | |
| 202 | + created_at: now.clone(), | |
| 203 | + updated_at: now, | |
| 204 | + }) | |
| 205 | + } | |
| 206 | + .await; | |
| 207 | + finish(tx, outcome).await | |
| 208 | + } | |
| 209 | + | |
| 210 | + /// The discussion with this number in the repo, if any. | |
| 211 | + pub async fn discussion_by_number( | |
| 212 | + &self, | |
| 213 | + repo_id: &str, | |
| 214 | + number: i64, | |
| 215 | + ) -> Result<Option<Discussion>> { | |
| 216 | + let conn = self.read_conn().await; | |
| 217 | + fetch_opt( | |
| 218 | + &conn, | |
| 219 | + &format!( | |
| 220 | + "SELECT {DISCUSSION_COLS} FROM discussions d WHERE d.repo_id = ?1 AND d.number = ?2 \ | |
| 221 | + LIMIT 1" | |
| 222 | + ), | |
| 223 | + (repo_id.to_string(), number), | |
| 224 | + map_discussion, | |
| 225 | + ) | |
| 226 | + .await | |
| 227 | + } | |
| 228 | + | |
| 229 | + /// One page of discussions, most recently active first, plus the total number of matches. | |
| 230 | + pub async fn list_discussions( | |
| 231 | + &self, | |
| 232 | + repo_id: &str, | |
| 233 | + f: &DiscussionFilter, | |
| 234 | + ) -> Result<(Vec<DiscussionListItem>, u64)> { | |
| 235 | + let mut clauses: Vec<&str> = vec!["d.repo_id = ?"]; | |
| 236 | + let mut params: Vec<Value> = vec![Value::Text(repo_id.to_string())]; | |
| 237 | + if let Some(category) = f.category { | |
| 238 | + clauses.push("d.category = ?"); | |
| 239 | + params.push(Value::Text(category.as_str().to_string())); | |
| 240 | + } | |
| 241 | + let where_sql = clauses.join(" AND "); | |
| 242 | + let per_page = f.per_page.clamp(1, 100); | |
| 243 | + let page = f.page.clamp(1, 1000); | |
| 244 | + let offset = i64::from(page - 1) * i64::from(per_page); | |
| 245 | + | |
| 246 | + let conn = self.read_conn().await; | |
| 247 | + let total = fetch_opt( | |
| 248 | + &conn, | |
| 249 | + &format!("SELECT COUNT(*) FROM discussions d WHERE {where_sql}"), | |
| 250 | + params.clone(), | |
| 251 | + |row| col_int(row, 0), | |
| 252 | + ) | |
| 253 | + .await? | |
| 254 | + .unwrap_or(0); | |
| 255 | + | |
| 256 | + let mut page_params = params; | |
| 257 | + page_params.push(Value::Integer(i64::from(per_page))); | |
| 258 | + page_params.push(Value::Integer(offset)); | |
| 259 | + let items = fetch_all( | |
| 260 | + &conn, | |
| 261 | + &format!( | |
| 262 | + "SELECT {DISCUSSION_COLS}, u.username FROM discussions d \ | |
| 263 | + JOIN users u ON u.id = d.author_id WHERE {where_sql} \ | |
| 264 | + ORDER BY d.updated_at DESC, d.number DESC LIMIT ? OFFSET ?" | |
| 265 | + ), | |
| 266 | + page_params, | |
| 267 | + |row| { | |
| 268 | + Ok(DiscussionListItem { | |
| 269 | + discussion: map_discussion(row)?, | |
| 270 | + author: col_text(row, 12)?, | |
| 271 | + }) | |
| 272 | + }, | |
| 273 | + ) | |
| 274 | + .await?; | |
| 275 | + Ok((items, u64::try_from(total).unwrap_or(0))) | |
| 276 | + } | |
| 277 | + | |
| 278 | + /// Replaces the title and body of a discussion. | |
| 279 | + pub async fn edit_discussion( | |
| 280 | + &self, | |
| 281 | + discussion_id: &str, | |
| 282 | + title: &str, | |
| 283 | + body: &str, | |
| 284 | + ) -> Result<()> { | |
| 285 | + let title = validate_discussion(title, body)?; | |
| 286 | + let conn = self.inner.conn.lock().await; | |
| 287 | + let changed = conn | |
| 288 | + .execute( | |
| 289 | + "UPDATE discussions SET title = ?2, body = ?3, updated_at = ?4 WHERE id = ?1", | |
| 290 | + ( | |
| 291 | + discussion_id.to_string(), | |
| 292 | + title, | |
| 293 | + body.to_string(), | |
| 294 | + now_stamp(), | |
| 295 | + ), | |
| 296 | + ) | |
| 297 | + .await | |
| 298 | + .map_err(db_err)?; | |
| 299 | + if changed == 0 { | |
| 300 | + return Err(Error::NotFound); | |
| 301 | + } | |
| 302 | + Ok(()) | |
| 303 | + } | |
| 304 | + | |
| 305 | + /// Locks or unlocks a discussion. Returns `false` when it was already in that state. | |
| 306 | + pub async fn set_discussion_locked(&self, discussion_id: &str, locked: bool) -> Result<bool> { | |
| 307 | + let conn = self.inner.conn.lock().await; | |
| 308 | + let changed = conn | |
| 309 | + .execute( | |
| 310 | + "UPDATE discussions SET locked = ?2 WHERE id = ?1 AND locked != ?2", | |
| 311 | + (discussion_id.to_string(), i64::from(locked)), | |
| 312 | + ) | |
| 313 | + .await | |
| 314 | + .map_err(db_err)?; | |
| 315 | + if changed > 0 { | |
| 316 | + return Ok(true); | |
| 317 | + } | |
| 318 | + discussion_row(&conn, discussion_id).await?; | |
| 319 | + Ok(false) | |
| 320 | + } | |
| 321 | + | |
| 322 | + /// Marks a comment as the answer, or clears the mark with `None`. Only Q&A discussions have an | |
| 323 | + /// answer, and the comment must be a live comment of that discussion. | |
| 324 | + pub async fn set_discussion_answer( | |
| 325 | + &self, | |
| 326 | + discussion_id: &str, | |
| 327 | + comment_id: Option<&str>, | |
| 328 | + ) -> Result<()> { | |
| 329 | + let conn = self.inner.conn.lock().await; | |
| 330 | + let discussion = discussion_row(&conn, discussion_id).await?; | |
| 331 | + if discussion.category != DiscussionCategory::Qa { | |
| 332 | + return Err(Error::Invalid("only Q&A discussions have an answer".into())); | |
| 333 | + } | |
| 334 | + if let Some(comment_id) = comment_id { | |
| 335 | + let live = fetch_opt( | |
| 336 | + &conn, | |
| 337 | + "SELECT 1 FROM discussion_comments WHERE id = ?1 AND discussion_id = ?2 \ | |
| 338 | + AND deleted_at IS NULL LIMIT 1", | |
| 339 | + (comment_id.to_string(), discussion_id.to_string()), | |
| 340 | + |row| col_int(row, 0), | |
| 341 | + ) | |
| 342 | + .await?; | |
| 343 | + if live.is_none() { | |
| 344 | + return Err(Error::Invalid( | |
| 345 | + "the answer must be a comment on this discussion".into(), | |
| 346 | + )); | |
| 347 | + } | |
| 348 | + } | |
| 349 | + conn.execute( | |
| 350 | + "UPDATE discussions SET answer_comment_id = ?2 WHERE id = ?1", | |
| 351 | + (discussion_id.to_string(), comment_id.map(str::to_string)), | |
| 352 | + ) | |
| 353 | + .await | |
| 354 | + .map_err(db_err)?; | |
| 355 | + Ok(()) | |
| 356 | + } | |
| 357 | + | |
| 358 | + /// Deletes a discussion with its comments, reactions and inbox rows. | |
| 359 | + pub async fn delete_discussion(&self, discussion_id: &str) -> Result<()> { | |
| 360 | + let conn = self.inner.conn.lock().await; | |
| 361 | + let tx = conn.unchecked_transaction().await.map_err(db_err)?; | |
| 362 | + let outcome: Result<()> = async { | |
| 363 | + discussion_row(&tx, discussion_id).await?; | |
| 364 | + let id = discussion_id.to_string(); | |
| 365 | + for sql in [ | |
| 366 | + "DELETE FROM reactions WHERE target_kind = 'discussion_comment' AND target_id IN \ | |
| 367 | + (SELECT id FROM discussion_comments WHERE discussion_id = ?1)", | |
| 368 | + "DELETE FROM reactions WHERE target_kind = 'discussion' AND target_id = ?1", | |
| 369 | + "DELETE FROM discussion_notifications WHERE discussion_id = ?1", | |
| 370 | + "DELETE FROM discussion_comments WHERE discussion_id = ?1", | |
| 371 | + "DELETE FROM discussions WHERE id = ?1", | |
| 372 | + ] { | |
| 373 | + tx.execute(sql, (id.clone(),)).await.map_err(db_err)?; | |
| 374 | + } | |
| 375 | + Ok(()) | |
| 376 | + } | |
| 377 | + .await; | |
| 378 | + finish(tx, outcome).await | |
| 379 | + } | |
| 380 | + | |
| 381 | + /// Adds a comment, or a reply when `parent_id` names a top-level live comment of the same | |
| 382 | + /// discussion. A reply to a reply is refused. Bumps the discussion's count and `updated_at`. | |
| 383 | + /// A locked discussion takes comments only from `triage` callers (Triage or higher). A comment | |
| 384 | + /// by someone other than the author also writes an inbox row for the author, reason `reply`. | |
| 385 | + pub async fn add_discussion_comment( | |
| 386 | + &self, | |
| 387 | + discussion_id: &str, | |
| 388 | + author_id: &str, | |
| 389 | + parent_id: Option<&str>, | |
| 390 | + body: &str, | |
| 391 | + triage: bool, | |
| 392 | + ) -> Result<DiscussionComment> { | |
| 393 | + validate_comment(body)?; | |
| 394 | + let conn = self.inner.conn.lock().await; | |
| 395 | + let tx = conn.unchecked_transaction().await.map_err(db_err)?; | |
| 396 | + let outcome: Result<DiscussionComment> = async { | |
| 397 | + let discussion = discussion_row(&tx, discussion_id).await?; | |
| 398 | + if discussion.locked && !triage { | |
| 399 | + return Err(Error::Forbidden("this discussion is locked".into())); | |
| 400 | + } | |
| 401 | + if let Some(parent) = parent_id { | |
| 402 | + let row = fetch_opt( | |
| 403 | + &tx, | |
| 404 | + &format!( | |
| 405 | + "SELECT {COMMENT_COLS} FROM discussion_comments c WHERE c.id = ?1 LIMIT 1" | |
| 406 | + ), | |
| 407 | + (parent.to_string(),), | |
| 408 | + map_comment, | |
| 409 | + ) | |
| 410 | + .await? | |
| 411 | + .ok_or(Error::NotFound)?; | |
| 412 | + if row.discussion_id != discussion_id { | |
| 413 | + return Err(Error::NotFound); | |
| 414 | + } | |
| 415 | + if row.parent_id.is_some() { | |
| 416 | + return Err(Error::Invalid("replies nest one level only".into())); | |
| 417 | + } | |
| 418 | + if row.deleted { | |
| 419 | + return Err(Error::Invalid("this comment was deleted".into())); | |
| 420 | + } | |
| 421 | + } | |
| 422 | + let now = now_stamp(); | |
| 423 | + let comment = DiscussionComment { | |
| 424 | + id: new_id(), | |
| 425 | + discussion_id: discussion_id.to_string(), | |
| 426 | + parent_id: parent_id.map(str::to_string), | |
| 427 | + author_id: author_id.to_string(), | |
| 428 | + body: body.to_string(), | |
| 429 | + created_at: now.clone(), | |
| 430 | + updated_at: now.clone(), | |
| 431 | + deleted: false, | |
| 432 | + }; | |
| 433 | + tx.execute( | |
| 434 | + "INSERT INTO discussion_comments (id, discussion_id, parent_id, author_id, body, \ | |
| 435 | + created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6)", | |
| 436 | + ( | |
| 437 | + comment.id.clone(), | |
| 438 | + comment.discussion_id.clone(), | |
| 439 | + comment.parent_id.clone(), | |
| 440 | + comment.author_id.clone(), | |
| 441 | + comment.body.clone(), | |
| 442 | + now.clone(), | |
| 443 | + ), | |
| 444 | + ) | |
| 445 | + .await | |
| 446 | + .map_err(db_err)?; | |
| 447 | + tx.execute( | |
| 448 | + "UPDATE discussions SET comment_count = comment_count + 1, updated_at = ?2 \ | |
| 449 | + WHERE id = ?1", | |
| 450 | + (discussion_id.to_string(), now.clone()), | |
| 451 | + ) | |
| 452 | + .await | |
| 453 | + .map_err(db_err)?; | |
| 454 | + if discussion.author_id != author_id { | |
| 455 | + tx.execute( | |
| 456 | + "INSERT INTO discussion_notifications (user_id, discussion_id, repo_id, reason, \ | |
| 457 | + last_actor_id, unread, updated_at) VALUES (?1, ?2, ?3, 'reply', ?4, 1, ?5) \ | |
| 458 | + ON CONFLICT (user_id, discussion_id) DO UPDATE SET \ | |
| 459 | + reason = CASE WHEN discussion_notifications.reason = 'mention' THEN 'mention' \ | |
| 460 | + ELSE 'reply' END, last_actor_id = excluded.last_actor_id, unread = 1, \ | |
| 461 | + updated_at = excluded.updated_at", | |
| 462 | + ( | |
| 463 | + discussion.author_id.clone(), | |
| 464 | + discussion_id.to_string(), | |
| 465 | + discussion.repo_id.clone(), | |
| 466 | + author_id.to_string(), | |
| 467 | + now, | |
| 468 | + ), | |
| 469 | + ) | |
| 470 | + .await | |
| 471 | + .map_err(db_err)?; | |
| 472 | + } | |
| 473 | + Ok(comment) | |
| 474 | + } | |
| 475 | + .await; | |
| 476 | + finish(tx, outcome).await | |
| 477 | + } | |
| 478 | + | |
| 479 | + /// The comment with this id in this discussion, if any. Deleted comments are returned too. | |
| 480 | + pub async fn discussion_comment( | |
| 481 | + &self, | |
| 482 | + discussion_id: &str, | |
| 483 | + comment_id: &str, | |
| 484 | + ) -> Result<Option<DiscussionComment>> { | |
| 485 | + let conn = self.read_conn().await; | |
| 486 | + fetch_opt( | |
| 487 | + &conn, | |
| 488 | + &format!( | |
| 489 | + "SELECT {COMMENT_COLS} FROM discussion_comments c WHERE c.id = ?1 \ | |
| 490 | + AND c.discussion_id = ?2 LIMIT 1" | |
| 491 | + ), | |
| 492 | + (comment_id.to_string(), discussion_id.to_string()), | |
| 493 | + map_comment, | |
| 494 | + ) | |
| 495 | + .await | |
| 496 | + } | |
| 497 | + | |
| 498 | + /// Replaces the body of a live comment. | |
| 499 | + pub async fn edit_discussion_comment(&self, comment_id: &str, body: &str) -> Result<()> { | |
| 500 | + validate_comment(body)?; | |
| 501 | + let conn = self.inner.conn.lock().await; | |
| 502 | + let changed = conn | |
| 503 | + .execute( | |
| 504 | + "UPDATE discussion_comments SET body = ?2, updated_at = ?3 \ | |
| 505 | + WHERE id = ?1 AND deleted_at IS NULL", | |
| 506 | + (comment_id.to_string(), body.to_string(), now_stamp()), | |
| 507 | + ) | |
| 508 | + .await | |
| 509 | + .map_err(db_err)?; | |
| 510 | + if changed == 0 { | |
| 511 | + return Err(Error::NotFound); | |
| 512 | + } | |
| 513 | + Ok(()) | |
| 514 | + } | |
| 515 | + | |
| 516 | + /// Deletes a comment. The row stays, so replies keep their parent: the body is emptied and | |
| 517 | + /// `deleted_at` is set. Its reactions go, and it stops being the answer. A comment that is | |
| 518 | + /// already deleted is left as it is. | |
| 519 | + pub async fn delete_discussion_comment(&self, comment_id: &str) -> Result<()> { | |
| 520 | + let conn = self.inner.conn.lock().await; | |
| 521 | + let tx = conn.unchecked_transaction().await.map_err(db_err)?; | |
| 522 | + let outcome: Result<()> = async { | |
| 523 | + let comment = fetch_opt( | |
| 524 | + &tx, | |
| 525 | + &format!("SELECT {COMMENT_COLS} FROM discussion_comments c WHERE c.id = ?1 LIMIT 1"), | |
| 526 | + (comment_id.to_string(),), | |
| 527 | + map_comment, | |
| 528 | + ) | |
| 529 | + .await? | |
| 530 | + .ok_or(Error::NotFound)?; | |
| 531 | + if comment.deleted { | |
| 532 | + return Ok(()); | |
| 533 | + } | |
| 534 | + let now = now_stamp(); | |
| 535 | + tx.execute( | |
| 536 | + "UPDATE discussion_comments SET body = '', deleted_at = ?2, updated_at = ?2 \ | |
| 537 | + WHERE id = ?1", | |
| 538 | + (comment_id.to_string(), now.clone()), | |
| 539 | + ) | |
| 540 | + .await | |
| 541 | + .map_err(db_err)?; | |
| 542 | + tx.execute( | |
| 543 | + "DELETE FROM reactions WHERE target_kind = 'discussion_comment' AND target_id = ?1", | |
| 544 | + (comment_id.to_string(),), | |
| 545 | + ) | |
| 546 | + .await | |
| 547 | + .map_err(db_err)?; | |
| 548 | + tx.execute( | |
| 549 | + "UPDATE discussions SET comment_count = CASE WHEN comment_count > 0 \ | |
| 550 | + THEN comment_count - 1 ELSE 0 END, updated_at = ?2 WHERE id = ?1", | |
| 551 | + (comment.discussion_id.clone(), now), | |
| 552 | + ) | |
| 553 | + .await | |
| 554 | + .map_err(db_err)?; | |
| 555 | + tx.execute( | |
| 556 | + "UPDATE discussions SET answer_comment_id = NULL WHERE id = ?1 AND answer_comment_id = ?2", | |
| 557 | + (comment.discussion_id, comment_id.to_string()), | |
| 558 | + ) | |
| 559 | + .await | |
| 560 | + .map_err(db_err)?; | |
| 561 | + Ok(()) | |
| 562 | + } | |
| 563 | + .await; | |
| 564 | + finish(tx, outcome).await | |
| 565 | + } | |
| 566 | + | |
| 567 | + /// Every comment of a discussion, oldest first, replies included. The page groups replies | |
| 568 | + /// under their parent. | |
| 569 | + pub async fn list_discussion_comments( | |
| 570 | + &self, | |
| 571 | + discussion_id: &str, | |
| 572 | + ) -> Result<Vec<DiscussionComment>> { | |
| 573 | + let conn = self.read_conn().await; | |
| 574 | + fetch_all( | |
| 575 | + &conn, | |
| 576 | + &format!( | |
| 577 | + "SELECT {COMMENT_COLS} FROM discussion_comments c WHERE c.discussion_id = ?1 \ | |
| 578 | + ORDER BY c.created_at, c.id" | |
| 579 | + ), | |
| 580 | + (discussion_id.to_string(),), | |
| 581 | + map_comment, | |
| 582 | + ) | |
| 583 | + .await | |
| 584 | + } | |
| 585 | + | |
| 586 | + /// Adds or refreshes an inbox row for each user in `user_ids` (mentioned in a discussion). The | |
| 587 | + /// row goes back to unread and records who mentioned them. | |
| 588 | + pub async fn record_discussion_mentions( | |
| 589 | + &self, | |
| 590 | + repo_id: &str, | |
| 591 | + discussion_id: &str, | |
| 592 | + actor_id: &str, | |
| 593 | + user_ids: &[String], | |
| 594 | + ) -> Result<()> { | |
| 595 | + if user_ids.is_empty() { | |
| 596 | + return Ok(()); | |
| 597 | + } | |
| 598 | + let conn = self.inner.conn.lock().await; | |
| 599 | + let now = now_stamp(); | |
| 600 | + for user_id in user_ids { | |
| 601 | + conn.execute( | |
| 602 | + "INSERT INTO discussion_notifications (user_id, discussion_id, repo_id, reason, \ | |
| 603 | + last_actor_id, unread, updated_at) VALUES (?1, ?2, ?3, 'mention', ?4, 1, ?5) \ | |
| 604 | + ON CONFLICT (user_id, discussion_id) DO UPDATE SET reason = 'mention', \ | |
| 605 | + last_actor_id = excluded.last_actor_id, unread = 1, updated_at = excluded.updated_at", | |
| 606 | + ( | |
| 607 | + user_id.clone(), | |
| 608 | + discussion_id.to_string(), | |
| 609 | + repo_id.to_string(), | |
| 610 | + actor_id.to_string(), | |
| 611 | + now.clone(), | |
| 612 | + ), | |
| 613 | + ) | |
| 614 | + .await | |
| 615 | + .map_err(db_err)?; | |
| 616 | + } | |
| 617 | + Ok(()) | |
| 618 | + } | |
| 619 | + | |
| 620 | + /// Unread inbox rows of a user for discussions: `(discussion_id, repo_id)` pairs, newest first. | |
| 621 | + pub async fn unread_discussion_mentions(&self, user_id: &str) -> Result<Vec<(String, String)>> { | |
| 622 | + let conn = self.read_conn().await; | |
| 623 | + fetch_all( | |
| 624 | + &conn, | |
| 625 | + "SELECT discussion_id, repo_id FROM discussion_notifications \ | |
| 626 | + WHERE user_id = ?1 AND unread = 1 ORDER BY updated_at DESC", | |
| 627 | + (user_id.to_string(),), | |
| 628 | + |row| Ok((col_text(row, 0)?, col_text(row, 1)?)), | |
| 629 | + ) | |
| 630 | + .await | |
| 631 | + } | |
| 632 | + | |
| 633 | + /// The user's inbox rows for discussions, newest first. `unread_only`, `before` and `limit` | |
| 634 | + /// mean what they mean in `list_notifications`. | |
| 635 | + pub async fn list_discussion_notifications( | |
| 636 | + &self, | |
| 637 | + user_id: &str, | |
| 638 | + unread_only: bool, | |
| 639 | + before: Option<&str>, | |
| 640 | + limit: u32, | |
| 641 | + ) -> Result<Vec<DiscussionNotification>> { | |
| 642 | + let mut sql = format!("{DISCUSSION_NOTE_SELECT} WHERE n.user_id = ?"); | |
| 643 | + let mut params = vec![Value::Text(user_id.to_string())]; | |
| 644 | + if unread_only { | |
| 645 | + sql.push_str(" AND n.unread = 1"); | |
| 646 | + } | |
| 647 | + if let Some(stamp) = before { | |
| 648 | + sql.push_str(" AND n.updated_at < ?"); | |
| 649 | + params.push(Value::Text(stamp.to_string())); | |
| 650 | + } | |
| 651 | + sql.push_str(" ORDER BY n.updated_at DESC, n.discussion_id DESC LIMIT ?"); | |
| 652 | + params.push(Value::Integer(i64::from(limit.clamp(1, 200)))); | |
| 653 | + let conn = self.read_conn().await; | |
| 654 | + fetch_all(&conn, &sql, params, map_discussion_note).await | |
| 655 | + } | |
| 656 | + | |
| 657 | + /// The user's inbox row for one discussion, if there is one. | |
| 658 | + pub async fn discussion_notification( | |
| 659 | + &self, | |
| 660 | + user_id: &str, | |
| 661 | + discussion_id: &str, | |
| 662 | + ) -> Result<Option<DiscussionNotification>> { | |
| 663 | + let conn = self.read_conn().await; | |
| 664 | + fetch_opt( | |
| 665 | + &conn, | |
| 666 | + &format!( | |
| 667 | + "{DISCUSSION_NOTE_SELECT} WHERE n.user_id = ?1 AND n.discussion_id = ?2 LIMIT 1" | |
| 668 | + ), | |
| 669 | + (user_id.to_string(), discussion_id.to_string()), | |
| 670 | + map_discussion_note, | |
| 671 | + ) | |
| 672 | + .await | |
| 673 | + } | |
| 674 | + | |
| 675 | + /// Marks the user's inbox row for one discussion read. Another user's row changes nothing. | |
| 676 | + pub async fn mark_discussion_notification_read( | |
| 677 | + &self, | |
| 678 | + user_id: &str, | |
| 679 | + discussion_id: &str, | |
| 680 | + ) -> Result<()> { | |
| 681 | + let conn = self.inner.conn.lock().await; | |
| 682 | + conn.execute( | |
| 683 | + "UPDATE discussion_notifications SET unread = 0 WHERE user_id = ?1 AND discussion_id = ?2", | |
| 684 | + (user_id.to_string(), discussion_id.to_string()), | |
| 685 | + ) | |
| 686 | + .await | |
| 687 | + .map_err(db_err)?; | |
| 688 | + Ok(()) | |
| 689 | + } | |
| 690 | + | |
| 691 | + /// Marks the user's unread discussion rows read, in one repo when `repo_id` is given, otherwise | |
| 692 | + /// all of them. | |
| 693 | + pub async fn mark_all_discussion_notifications_read( | |
| 694 | + &self, | |
| 695 | + user_id: &str, | |
| 696 | + repo_id: Option<&str>, | |
| 697 | + ) -> Result<()> { | |
| 698 | + let conn = self.inner.conn.lock().await; | |
| 699 | + let result = match repo_id { | |
| 700 | + Some(repo) => { | |
| 701 | + conn.execute( | |
| 702 | + "UPDATE discussion_notifications SET unread = 0 WHERE user_id = ?1 AND repo_id = ?2 AND unread = 1", | |
| 703 | + (user_id.to_string(), repo.to_string()), | |
| 704 | + ) | |
| 705 | + .await | |
| 706 | + } | |
| 707 | + None => { | |
| 708 | + conn.execute( | |
| 709 | + "UPDATE discussion_notifications SET unread = 0 WHERE user_id = ?1 AND unread = 1", | |
| 710 | + (user_id.to_string(),), | |
| 711 | + ) | |
| 712 | + .await | |
| 713 | + } | |
| 714 | + }; | |
| 715 | + result.map_err(db_err)?; | |
| 716 | + Ok(()) | |
| 717 | + } | |
| 718 | +} | |
| 719 | + | |
| 720 | +#[cfg(test)] | |
| 721 | +mod tests { | |
| 722 | + use super::*; | |
| 723 | + use crate::model::{User, Visibility}; | |
| 724 | + | |
| 725 | + struct Fixture { | |
| 726 | + store: Store, | |
| 727 | + alice: User, | |
| 728 | + bob: User, | |
| 729 | + repo_id: String, | |
| 730 | + _dir: tempfile::TempDir, | |
| 731 | + } | |
| 732 | + | |
| 733 | + async fn fixture() -> Fixture { | |
| 734 | + let dir = tempfile::tempdir().unwrap(); | |
| 735 | + let store = Store::open(&dir.path().join("forge.db")).await.unwrap(); | |
| 736 | + let alice = store | |
| 737 | + .create_user("Alice", None, "hash", false) | |
| 738 | + .await | |
| 739 | + .unwrap(); | |
| 740 | + let bob = store.create_user("Bob", None, "hash", false).await.unwrap(); | |
| 741 | + let repo = store | |
| 742 | + .create_repo(&alice.id, "proj", "", Visibility::Public, "main") | |
| 743 | + .await | |
| 744 | + .unwrap(); | |
| 745 | + Fixture { | |
| 746 | + store, | |
| 747 | + alice, | |
| 748 | + bob, | |
| 749 | + repo_id: repo.id, | |
| 750 | + _dir: dir, | |
| 751 | + } | |
| 752 | + } | |
| 753 | + | |
| 754 | + fn filter(category: Option<DiscussionCategory>) -> DiscussionFilter { | |
| 755 | + DiscussionFilter { | |
| 756 | + category, | |
| 757 | + page: 1, | |
| 758 | + per_page: 25, | |
| 759 | + } | |
| 760 | + } | |
| 761 | + | |
| 762 | + #[tokio::test] | |
| 763 | + async fn discussions_are_off_by_default_and_toggle() { | |
| 764 | + let fx = fixture().await; | |
| 765 | + assert!(!fx.store.discussions_enabled(&fx.repo_id).await.unwrap()); | |
| 766 | + fx.store | |
| 767 | + .set_discussions_enabled(&fx.repo_id, true) | |
| 768 | + .await | |
| 769 | + .unwrap(); | |
| 770 | + assert!(fx.store.discussions_enabled(&fx.repo_id).await.unwrap()); | |
| 771 | + assert!(matches!( | |
| 772 | + fx.store.set_discussions_enabled("missing", true).await, | |
| 773 | + Err(Error::NotFound) | |
| 774 | + )); | |
| 775 | + } | |
| 776 | + | |
| 777 | + #[tokio::test] | |
| 778 | + async fn numbers_share_the_issue_counter() { | |
| 779 | + let fx = fixture().await; | |
| 780 | + let issue = fx | |
| 781 | + .store | |
| 782 | + .create_issue(&fx.repo_id, &fx.alice.id, "first", "") | |
| 783 | + .await | |
| 784 | + .unwrap(); | |
| 785 | + let d1 = fx | |
| 786 | + .store | |
| 787 | + .create_discussion( | |
| 788 | + &fx.repo_id, | |
| 789 | + &fx.alice.id, | |
| 790 | + DiscussionCategory::General, | |
| 791 | + "a", | |
| 792 | + "", | |
| 793 | + ) | |
| 794 | + .await | |
| 795 | + .unwrap(); | |
| 796 | + let issue2 = fx | |
| 797 | + .store | |
| 798 | + .create_issue(&fx.repo_id, &fx.alice.id, "second", "") | |
| 799 | + .await | |
| 800 | + .unwrap(); | |
| 801 | + assert_eq!(issue.number, 1); | |
| 802 | + assert_eq!(d1.number, 2); | |
| 803 | + assert_eq!(issue2.number, 3); | |
| 804 | + let found = fx.store.discussion_by_number(&fx.repo_id, 2).await.unwrap(); | |
| 805 | + assert_eq!(found.map(|d| d.title), Some("a".to_string())); | |
| 806 | + } | |
| 807 | + | |
| 808 | + #[tokio::test] | |
| 809 | + async fn create_rejects_blank_title_and_lists_by_category() { | |
| 810 | + let fx = fixture().await; | |
| 811 | + assert!(matches!( | |
| 812 | + fx.store | |
| 813 | + .create_discussion( | |
| 814 | + &fx.repo_id, | |
| 815 | + &fx.alice.id, | |
| 816 | + DiscussionCategory::Ideas, | |
| 817 | + " ", | |
| 818 | + "" | |
| 819 | + ) | |
| 820 | + .await, | |
| 821 | + Err(Error::Invalid(_)) | |
| 822 | + )); | |
| 823 | + fx.store | |
| 824 | + .create_discussion( | |
| 825 | + &fx.repo_id, | |
| 826 | + &fx.alice.id, | |
| 827 | + DiscussionCategory::Ideas, | |
| 828 | + "idea", | |
| 829 | + "", | |
| 830 | + ) | |
| 831 | + .await | |
| 832 | + .unwrap(); | |
| 833 | + fx.store | |
| 834 | + .create_discussion( | |
| 835 | + &fx.repo_id, | |
| 836 | + &fx.bob.id, | |
| 837 | + DiscussionCategory::Qa, | |
| 838 | + "question", | |
| 839 | + "", | |
| 840 | + ) | |
| 841 | + .await | |
| 842 | + .unwrap(); | |
| 843 | + let (all, total) = fx | |
| 844 | + .store | |
| 845 | + .list_discussions(&fx.repo_id, &filter(None)) | |
| 846 | + .await | |
| 847 | + .unwrap(); | |
| 848 | + assert_eq!((all.len(), total), (2, 2)); | |
| 849 | + let (qa, total) = fx | |
| 850 | + .store | |
| 851 | + .list_discussions(&fx.repo_id, &filter(Some(DiscussionCategory::Qa))) | |
| 852 | + .await | |
| 853 | + .unwrap(); | |
| 854 | + assert_eq!(total, 1); | |
| 855 | + assert_eq!(qa[0].discussion.title, "question"); | |
| 856 | + assert_eq!(qa[0].author, "Bob"); | |
| 857 | + } | |
| 858 | + | |
| 859 | + #[tokio::test] | |
| 860 | + async fn comments_nest_one_level_and_count() { | |
| 861 | + let fx = fixture().await; | |
| 862 | + let d = fx | |
| 863 | + .store | |
| 864 | + .create_discussion( | |
| 865 | + &fx.repo_id, | |
| 866 | + &fx.alice.id, | |
| 867 | + DiscussionCategory::General, | |
| 868 | + "t", | |
| 869 | + "", | |
| 870 | + ) | |
| 871 | + .await | |
| 872 | + .unwrap(); | |
| 873 | + let top = fx | |
| 874 | + .store | |
| 875 | + .add_discussion_comment(&d.id, &fx.bob.id, None, "hello", false) | |
| 876 | + .await | |
| 877 | + .unwrap(); | |
| 878 | + let reply = fx | |
| 879 | + .store | |
| 880 | + .add_discussion_comment(&d.id, &fx.alice.id, Some(&top.id), "hi back", false) | |
| 881 | + .await | |
| 882 | + .unwrap(); | |
| 883 | + assert_eq!(reply.parent_id.as_deref(), Some(top.id.as_str())); | |
| 884 | + let nested = fx | |
| 885 | + .store | |
| 886 | + .add_discussion_comment(&d.id, &fx.bob.id, Some(&reply.id), "too deep", false) | |
| 887 | + .await; | |
| 888 | + assert!(matches!(nested, Err(Error::Invalid(_)))); | |
| 889 | + assert!(matches!( | |
| 890 | + fx.store | |
| 891 | + .add_discussion_comment(&d.id, &fx.bob.id, None, " ", false) | |
| 892 | + .await, | |
| 893 | + Err(Error::Invalid(_)) | |
| 894 | + )); | |
| 895 | + let stored = fx | |
| 896 | + .store | |
| 897 | + .discussion_by_number(&fx.repo_id, d.number) | |
| 898 | + .await | |
| 899 | + .unwrap(); | |
| 900 | + assert_eq!(stored.map(|d| d.comment_count), Some(2)); | |
| 901 | + let all = fx.store.list_discussion_comments(&d.id).await.unwrap(); | |
| 902 | + assert_eq!(all.len(), 2); | |
| 903 | + } | |
| 904 | + | |
| 905 | + #[tokio::test] | |
| 906 | + async fn answer_only_in_qa_and_only_a_live_comment_of_it() { | |
| 907 | + let fx = fixture().await; | |
| 908 | + let qa = fx | |
| 909 | + .store | |
| 910 | + .create_discussion(&fx.repo_id, &fx.alice.id, DiscussionCategory::Qa, "q", "") | |
| 911 | + .await | |
| 912 | + .unwrap(); | |
| 913 | + let general = fx | |
| 914 | + .store | |
| 915 | + .create_discussion( | |
| 916 | + &fx.repo_id, | |
| 917 | + &fx.alice.id, | |
| 918 | + DiscussionCategory::General, | |
| 919 | + "g", | |
| 920 | + "", | |
| 921 | + ) | |
| 922 | + .await | |
| 923 | + .unwrap(); | |
| 924 | + let c = fx | |
| 925 | + .store | |
| 926 | + .add_discussion_comment(&qa.id, &fx.bob.id, None, "answer", false) | |
| 927 | + .await | |
| 928 | + .unwrap(); | |
| 929 | + let g = fx | |
| 930 | + .store | |
| 931 | + .add_discussion_comment(&general.id, &fx.bob.id, None, "x", false) | |
| 932 | + .await | |
| 933 | + .unwrap(); | |
| 934 | + assert!(matches!( | |
| 935 | + fx.store | |
| 936 | + .set_discussion_answer(&general.id, Some(&g.id)) | |
| 937 | + .await, | |
| 938 | + Err(Error::Invalid(_)) | |
| 939 | + )); | |
| 940 | + assert!(matches!( | |
| 941 | + fx.store.set_discussion_answer(&qa.id, Some(&g.id)).await, | |
| 942 | + Err(Error::Invalid(_)) | |
| 943 | + )); | |
| 944 | + fx.store | |
| 945 | + .set_discussion_answer(&qa.id, Some(&c.id)) | |
| 946 | + .await | |
| 947 | + .unwrap(); | |
| 948 | + let stored = fx | |
| 949 | + .store | |
| 950 | + .discussion_by_number(&fx.repo_id, qa.number) | |
| 951 | + .await | |
| 952 | + .unwrap(); | |
| 953 | + assert_eq!(stored.and_then(|d| d.answer_comment_id), Some(c.id.clone())); | |
| 954 | + fx.store.delete_discussion_comment(&c.id).await.unwrap(); | |
| 955 | + let stored = fx | |
| 956 | + .store | |
| 957 | + .discussion_by_number(&fx.repo_id, qa.number) | |
| 958 | + .await | |
| 959 | + .unwrap(); | |
| 960 | + assert_eq!(stored.and_then(|d| d.answer_comment_id), None); | |
| 961 | + } | |
| 962 | + | |
| 963 | + #[tokio::test] | |
| 964 | + async fn lock_reports_changes_and_delete_comment_keeps_replies() { | |
| 965 | + let fx = fixture().await; | |
| 966 | + let d = fx | |
| 967 | + .store | |
| 968 | + .create_discussion( | |
| 969 | + &fx.repo_id, | |
| 970 | + &fx.alice.id, | |
| 971 | + DiscussionCategory::General, | |
| 972 | + "t", | |
| 973 | + "", | |
| 974 | + ) | |
| 975 | + .await | |
| 976 | + .unwrap(); | |
| 977 | + let top = fx | |
| 978 | + .store | |
| 979 | + .add_discussion_comment(&d.id, &fx.bob.id, None, "gone soon", false) | |
| 980 | + .await | |
| 981 | + .unwrap(); | |
| 982 | + fx.store | |
| 983 | + .add_discussion_comment(&d.id, &fx.alice.id, Some(&top.id), "reply stays", false) | |
| 984 | + .await | |
| 985 | + .unwrap(); | |
| 986 | + assert!(fx.store.set_discussion_locked(&d.id, true).await.unwrap()); | |
| 987 | + assert!(!fx.store.set_discussion_locked(&d.id, true).await.unwrap()); | |
| 988 | + assert!(matches!( | |
| 989 | + fx.store.set_discussion_locked("missing", true).await, | |
| 990 | + Err(Error::NotFound) | |
| 991 | + )); | |
| 992 | + fx.store.delete_discussion_comment(&top.id).await.unwrap(); | |
| 993 | + let gone = fx | |
| 994 | + .store | |
| 995 | + .discussion_comment(&d.id, &top.id) | |
| 996 | + .await | |
| 997 | + .unwrap() | |
| 998 | + .unwrap(); | |
| 999 | + assert!(gone.deleted); | |
| 1000 | + assert!(gone.body.is_empty()); | |
| 1001 | + let all = fx.store.list_discussion_comments(&d.id).await.unwrap(); | |
| 1002 | + assert_eq!(all.len(), 2); | |
| 1003 | + assert!(all.iter().any(|c| c.body == "reply stays" && !c.deleted)); | |
| 1004 | + } | |
| 1005 | + | |
| 1006 | + #[tokio::test] | |
| 1007 | + async fn edit_and_delete_discussion_remove_its_rows() { | |
| 1008 | + let fx = fixture().await; | |
| 1009 | + let d = fx | |
| 1010 | + .store | |
| 1011 | + .create_discussion( | |
| 1012 | + &fx.repo_id, | |
| 1013 | + &fx.alice.id, | |
| 1014 | + DiscussionCategory::Ideas, | |
| 1015 | + "old", | |
| 1016 | + "body", | |
| 1017 | + ) | |
| 1018 | + .await | |
| 1019 | + .unwrap(); | |
| 1020 | + fx.store | |
| 1021 | + .edit_discussion(&d.id, "new", "changed") | |
| 1022 | + .await | |
| 1023 | + .unwrap(); | |
| 1024 | + let edited = fx | |
| 1025 | + .store | |
| 1026 | + .discussion_by_number(&fx.repo_id, d.number) | |
| 1027 | + .await | |
| 1028 | + .unwrap(); | |
| 1029 | + assert_eq!(edited.map(|d| d.title), Some("new".to_string())); | |
| 1030 | + fx.store | |
| 1031 | + .add_discussion_comment(&d.id, &fx.bob.id, None, "c", false) | |
| 1032 | + .await | |
| 1033 | + .unwrap(); | |
| 1034 | + fx.store.delete_discussion(&d.id).await.unwrap(); | |
| 1035 | + assert!( | |
| 1036 | + fx.store | |
| 1037 | + .discussion_by_number(&fx.repo_id, d.number) | |
| 1038 | + .await | |
| 1039 | + .unwrap() | |
| 1040 | + .is_none() | |
| 1041 | + ); | |
| 1042 | + assert!( | |
| 1043 | + fx.store | |
| 1044 | + .list_discussion_comments(&d.id) | |
| 1045 | + .await | |
| 1046 | + .unwrap() | |
| 1047 | + .is_empty() | |
| 1048 | + ); | |
| 1049 | + assert!(matches!( | |
| 1050 | + fx.store.delete_discussion(&d.id).await, | |
| 1051 | + Err(Error::NotFound) | |
| 1052 | + )); | |
| 1053 | + } | |
| 1054 | + | |
| 1055 | + #[tokio::test] | |
| 1056 | + async fn mentions_go_unread_and_refresh_on_repeat() { | |
| 1057 | + let fx = fixture().await; | |
| 1058 | + let d = fx | |
| 1059 | + .store | |
| 1060 | + .create_discussion( | |
| 1061 | + &fx.repo_id, | |
| 1062 | + &fx.alice.id, | |
| 1063 | + DiscussionCategory::General, | |
| 1064 | + "t", | |
| 1065 | + "", | |
| 1066 | + ) | |
| 1067 | + .await | |
| 1068 | + .unwrap(); | |
| 1069 | + fx.store | |
| 1070 | + .record_discussion_mentions( | |
| 1071 | + &fx.repo_id, | |
| 1072 | + &d.id, | |
| 1073 | + &fx.alice.id, | |
| 1074 | + std::slice::from_ref(&fx.bob.id), | |
| 1075 | + ) | |
| 1076 | + .await | |
| 1077 | + .unwrap(); | |
| 1078 | + fx.store | |
| 1079 | + .record_discussion_mentions( | |
| 1080 | + &fx.repo_id, | |
| 1081 | + &d.id, | |
| 1082 | + &fx.alice.id, | |
| 1083 | + std::slice::from_ref(&fx.bob.id), | |
| 1084 | + ) | |
| 1085 | + .await | |
| 1086 | + .unwrap(); | |
| 1087 | + let rows = fx | |
| 1088 | + .store | |
| 1089 | + .unread_discussion_mentions(&fx.bob.id) | |
| 1090 | + .await | |
| 1091 | + .unwrap(); | |
| 1092 | + assert_eq!(rows, vec![(d.id.clone(), fx.repo_id.clone())]); | |
| 1093 | + assert!( | |
| 1094 | + fx.store | |
| 1095 | + .unread_discussion_mentions(&fx.alice.id) | |
| 1096 | + .await | |
| 1097 | + .unwrap() | |
| 1098 | + .is_empty() | |
| 1099 | + ); | |
| 1100 | + } | |
| 1101 | + | |
| 1102 | + #[tokio::test] | |
| 1103 | + async fn locked_discussion_takes_comments_only_from_triage() { | |
| 1104 | + let fx = fixture().await; | |
| 1105 | + let d = fx | |
| 1106 | + .store | |
| 1107 | + .create_discussion( | |
| 1108 | + &fx.repo_id, | |
| 1109 | + &fx.alice.id, | |
| 1110 | + DiscussionCategory::General, | |
| 1111 | + "t", | |
| 1112 | + "", | |
| 1113 | + ) | |
| 1114 | + .await | |
| 1115 | + .unwrap(); | |
| 1116 | + fx.store.set_discussion_locked(&d.id, true).await.unwrap(); | |
| 1117 | + let refused = fx | |
| 1118 | + .store | |
| 1119 | + .add_discussion_comment(&d.id, &fx.bob.id, None, "hi", false) | |
| 1120 | + .await; | |
| 1121 | + assert!(matches!(refused, Err(Error::Forbidden(_)))); | |
| 1122 | + let stored = fx | |
| 1123 | + .store | |
| 1124 | + .discussion_by_number(&fx.repo_id, d.number) | |
| 1125 | + .await | |
| 1126 | + .unwrap() | |
| 1127 | + .unwrap(); | |
| 1128 | + assert_eq!(stored.comment_count, 0); | |
| 1129 | + fx.store | |
| 1130 | + .add_discussion_comment(&d.id, &fx.bob.id, None, "maintainer note", true) | |
| 1131 | + .await | |
| 1132 | + .unwrap(); | |
| 1133 | + fx.store.set_discussion_locked(&d.id, false).await.unwrap(); | |
| 1134 | + fx.store | |
| 1135 | + .add_discussion_comment(&d.id, &fx.bob.id, None, "open again", false) | |
| 1136 | + .await | |
| 1137 | + .unwrap(); | |
| 1138 | + } | |
| 1139 | + | |
| 1140 | + #[tokio::test] | |
| 1141 | + async fn replies_and_mentions_reach_the_inbox() { | |
| 1142 | + let fx = fixture().await; | |
| 1143 | + let d = fx | |
| 1144 | + .store | |
| 1145 | + .create_discussion(&fx.repo_id, &fx.alice.id, DiscussionCategory::Qa, "Q", "") | |
| 1146 | + .await | |
| 1147 | + .unwrap(); | |
| 1148 | + // Bob's comment on Alice's discussion gives Alice a reply row. | |
| 1149 | + fx.store | |
| 1150 | + .add_discussion_comment(&d.id, &fx.bob.id, None, "answer", false) | |
| 1151 | + .await | |
| 1152 | + .unwrap(); | |
| 1153 | + // Alice's own comment gives her nothing. | |
| 1154 | + fx.store | |
| 1155 | + .add_discussion_comment(&d.id, &fx.alice.id, None, "thanks", false) | |
| 1156 | + .await | |
| 1157 | + .unwrap(); | |
| 1158 | + let rows = fx | |
| 1159 | + .store | |
| 1160 | + .list_discussion_notifications(&fx.alice.id, true, None, 50) | |
| 1161 | + .await | |
| 1162 | + .unwrap(); | |
| 1163 | + assert_eq!(rows.len(), 1); | |
| 1164 | + assert_eq!(rows[0].reason, DiscussionReason::Reply); | |
| 1165 | + assert_eq!(rows[0].title, "Q"); | |
| 1166 | + assert_eq!(rows[0].number, d.number); | |
| 1167 | + assert_eq!(rows[0].repo_owner, "Alice"); | |
| 1168 | + assert_eq!(rows[0].last_actor_name.as_deref(), Some("Bob")); | |
| 1169 | + | |
| 1170 | + // A mention upgrades the row, and a later reply does not downgrade it. | |
| 1171 | + fx.store | |
| 1172 | + .record_discussion_mentions( | |
| 1173 | + &fx.repo_id, | |
| 1174 | + &d.id, | |
| 1175 | + &fx.bob.id, | |
| 1176 | + std::slice::from_ref(&fx.alice.id), | |
| 1177 | + ) | |
| 1178 | + .await | |
| 1179 | + .unwrap(); | |
| 1180 | + fx.store | |
| 1181 | + .add_discussion_comment(&d.id, &fx.bob.id, None, "again", false) | |
| 1182 | + .await | |
| 1183 | + .unwrap(); | |
| 1184 | + let rows = fx | |
| 1185 | + .store | |
| 1186 | + .list_discussion_notifications(&fx.alice.id, true, None, 50) | |
| 1187 | + .await | |
| 1188 | + .unwrap(); | |
| 1189 | + assert_eq!(rows.len(), 1); | |
| 1190 | + assert_eq!(rows[0].reason, DiscussionReason::Mention); | |
| 1191 | + assert_eq!(fx.store.unread_count(&fx.alice.id).await.unwrap(), 1); | |
| 1192 | + | |
| 1193 | + // Another user's mark-read changes nothing. | |
| 1194 | + fx.store | |
| 1195 | + .mark_discussion_notification_read(&fx.bob.id, &d.id) | |
| 1196 | + .await | |
| 1197 | + .unwrap(); | |
| 1198 | + assert_eq!(fx.store.unread_count(&fx.alice.id).await.unwrap(), 1); | |
| 1199 | + fx.store | |
| 1200 | + .mark_all_discussion_notifications_read(&fx.alice.id, Some(&fx.repo_id)) | |
| 1201 | + .await | |
| 1202 | + .unwrap(); | |
| 1203 | + assert_eq!(fx.store.unread_count(&fx.alice.id).await.unwrap(), 0); | |
| 1204 | + assert!( | |
| 1205 | + fx.store | |
| 1206 | + .list_discussion_notifications(&fx.alice.id, true, None, 50) | |
| 1207 | + .await | |
| 1208 | + .unwrap() | |
| 1209 | + .is_empty() | |
| 1210 | + ); | |
| 1211 | + let all = fx | |
| 1212 | + .store | |
| 1213 | + .list_discussion_notifications(&fx.alice.id, false, None, 50) | |
| 1214 | + .await | |
| 1215 | + .unwrap(); | |
| 1216 | + assert_eq!(all.len(), 1); | |
| 1217 | + assert!(!all[0].unread); | |
| 1218 | + assert!( | |
| 1219 | + fx.store | |
| 1220 | + .discussion_notification(&fx.bob.id, &d.id) | |
| 1221 | + .await | |
| 1222 | + .unwrap() | |
| 1223 | + .is_none() | |
| 1224 | + ); | |
| 1225 | + } | |
| 1226 | +} |
crates/forge-core/src/store/edit_history_read.rs| @@ -0,0 +1,104 @@ | ||
| 1 | +//! Reads of the edit history written by `edit_issue`, `edit_comment` and `edit_review_comment`. | |
| 2 | +//! Each row holds the text an edit replaced, so a row's text is the version that was live before | |
| 3 | +//! `edited_at`, and its editor is the person who replaced it. | |
| 4 | + | |
| 5 | +use std::collections::HashMap; | |
| 6 | + | |
| 7 | +use super::{Store, col_int, col_opt_text, col_text, fetch_all, fetch_opt}; | |
| 8 | +use crate::error::Result; | |
| 9 | + | |
| 10 | +/// Longest id list sent in one `IN (...)` query. | |
| 11 | +const CHUNK: usize = 500; | |
| 12 | + | |
| 13 | +/// One previous version of an issue, pull request, comment or review comment. | |
| 14 | +#[derive(Debug, Clone, PartialEq)] | |
| 15 | +pub struct EditVersion { | |
| 16 | + pub id: String, | |
| 17 | + /// The username of the person who made the edit that replaced this version. | |
| 18 | + pub editor_name: String, | |
| 19 | + pub edited_at: String, | |
| 20 | + /// The title it had, for issues and pull requests. | |
| 21 | + pub old_title: Option<String>, | |
| 22 | + pub old_body: String, | |
| 23 | +} | |
| 24 | + | |
| 25 | +fn placeholders(n: usize) -> String { | |
| 26 | + vec!["?"; n].join(",") | |
| 27 | +} | |
| 28 | + | |
| 29 | +impl Store { | |
| 30 | + /// The previous versions of one target, newest first. | |
| 31 | + pub async fn edit_history( | |
| 32 | + &self, | |
| 33 | + target_kind: &str, | |
| 34 | + target_id: &str, | |
| 35 | + ) -> Result<Vec<EditVersion>> { | |
| 36 | + let conn = self.read_conn().await; | |
| 37 | + fetch_all( | |
| 38 | + &conn, | |
| 39 | + "SELECT h.id, u.username, h.edited_at, h.old_title, h.old_body \ | |
| 40 | + FROM edit_history h JOIN users u ON u.id = h.editor_id \ | |
| 41 | + WHERE h.target_kind = ?1 AND h.target_id = ?2 \ | |
| 42 | + ORDER BY h.edited_at DESC, h.id DESC", | |
| 43 | + (target_kind.to_string(), target_id.to_string()), | |
| 44 | + |row| { | |
| 45 | + Ok(EditVersion { | |
| 46 | + id: col_text(row, 0)?, | |
| 47 | + editor_name: col_text(row, 1)?, | |
| 48 | + edited_at: col_text(row, 2)?, | |
| 49 | + old_title: col_opt_text(row, 3)?, | |
| 50 | + old_body: col_text(row, 4)?, | |
| 51 | + }) | |
| 52 | + }, | |
| 53 | + ) | |
| 54 | + .await | |
| 55 | + } | |
| 56 | + | |
| 57 | + /// How many previous versions each target has, keyed by target id. Targets with no edits are | |
| 58 | + /// absent from the map. One query per 500 ids. | |
| 59 | + pub async fn edit_count( | |
| 60 | + &self, | |
| 61 | + target_kind: &str, | |
| 62 | + target_ids: &[&str], | |
| 63 | + ) -> Result<HashMap<String, u64>> { | |
| 64 | + let mut out = HashMap::new(); | |
| 65 | + if target_ids.is_empty() { | |
| 66 | + return Ok(out); | |
| 67 | + } | |
| 68 | + let conn = self.read_conn().await; | |
| 69 | + for chunk in target_ids.chunks(CHUNK) { | |
| 70 | + let sql = format!( | |
| 71 | + "SELECT target_id, COUNT(*) FROM edit_history \ | |
| 72 | + WHERE target_kind = ?1 AND target_id IN ({}) GROUP BY target_id", | |
| 73 | + placeholders(chunk.len()) | |
| 74 | + ); | |
| 75 | + let mut params: Vec<turso::Value> = Vec::with_capacity(chunk.len() + 1); | |
| 76 | + params.push(turso::Value::Text(target_kind.to_string())); | |
| 77 | + params.extend(chunk.iter().map(|id| turso::Value::Text((*id).to_string()))); | |
| 78 | + let rows = fetch_all(&conn, &sql, params, |row| { | |
| 79 | + let count = u64::try_from(col_int(row, 1)?).unwrap_or(0); | |
| 80 | + Ok((col_text(row, 0)?, count)) | |
| 81 | + }) | |
| 82 | + .await?; | |
| 83 | + out.extend(rows); | |
| 84 | + } | |
| 85 | + Ok(out) | |
| 86 | + } | |
| 87 | + | |
| 88 | + /// The issue a comment, review comment or issue belongs to, by target id. `None` when the | |
| 89 | + /// target does not exist (a deleted comment has no history either). | |
| 90 | + pub async fn edit_target_issue( | |
| 91 | + &self, | |
| 92 | + target_kind: &str, | |
| 93 | + target_id: &str, | |
| 94 | + ) -> Result<Option<String>> { | |
| 95 | + let sql = match target_kind { | |
| 96 | + "issue" => "SELECT id FROM issues WHERE id = ?1 LIMIT 1", | |
| 97 | + "comment" => "SELECT issue_id FROM comments WHERE id = ?1 LIMIT 1", | |
| 98 | + "review_comment" => "SELECT issue_id FROM review_comments WHERE id = ?1 LIMIT 1", | |
| 99 | + _ => return Ok(None), | |
| 100 | + }; | |
| 101 | + let conn = self.read_conn().await; | |
| 102 | + fetch_opt(&conn, sql, (target_id.to_string(),), |row| col_text(row, 0)).await | |
| 103 | + } | |
| 104 | +} |
crates/forge-core/src/store/issues.rs| @@ -0,0 +1,1730 @@ | ||
| 1 | +//! Issues, comments, timeline events and milestones. Every multi-row write runs in one | |
| 2 | +//! transaction. Numbers come from `repos.next_issue_number` in the same transaction as the insert. | |
| 3 | + | |
| 4 | +use std::collections::HashMap; | |
| 5 | + | |
| 6 | +use serde_json::{Map, Value as Json}; | |
| 7 | +use turso::transaction::Transaction; | |
| 8 | +use turso::{Connection, Row, Value}; | |
| 9 | + | |
| 10 | +use super::{ | |
| 11 | + Store, col_int, col_opt_text, col_text, db_err, fetch_all, fetch_opt, insert_err, missing_row, | |
| 12 | + new_id, | |
| 13 | +}; | |
| 14 | +use crate::error::{Error, Result}; | |
| 15 | +use crate::issues::{ | |
| 16 | + Comment, Issue, IssueEvent, IssueListItem, IssueSort, IssueState, LOCK_REASONS, Label, | |
| 17 | + StateReason, validate_body, validate_title, | |
| 18 | +}; | |
| 19 | +use crate::names::normalize_name; | |
| 20 | +use crate::time::now_stamp; | |
| 21 | + | |
| 22 | +/// Event kinds the timeline knows. Anything else is rejected by `add_event`. | |
| 23 | +const EVENT_KINDS: &[&str] = &[ | |
| 24 | + "closed", | |
| 25 | + "reopened", | |
| 26 | + "labeled", | |
| 27 | + "unlabeled", | |
| 28 | + "assigned", | |
| 29 | + "unassigned", | |
| 30 | + "milestoned", | |
| 31 | + "demilestoned", | |
| 32 | + "renamed", | |
| 33 | + "referenced", | |
| 34 | + "locked", | |
| 35 | + "unlocked", | |
| 36 | + // Pull requests (store/pulls.rs). | |
| 37 | + "merged", | |
| 38 | + "head_updated", | |
| 39 | + "base_changed", | |
| 40 | + "head_deleted", | |
| 41 | + "head_ref_deleted", | |
| 42 | + "head_ref_restored", | |
| 43 | + // Pull request reviews (store/reviews.rs). | |
| 44 | + "review_dismissed", | |
| 45 | + "thread_resolved", | |
| 46 | + "thread_unresolved", | |
| 47 | + // Pinned issues (store/pinned.rs). | |
| 48 | + "pinned", | |
| 49 | + "unpinned", | |
| 50 | +]; | |
| 51 | + | |
| 52 | +pub(super) const ISSUE_COLS: &str = "i.id, i.repo_id, i.number, i.title, i.body, i.state, i.state_reason, \ | |
| 53 | + i.author_id, i.milestone_id, i.comment_count, i.created_at, i.updated_at, \ | |
| 54 | + i.closed_at, i.locked, i.lock_reason"; | |
| 55 | +const COMMENT_COLS: &str = "id, issue_id, author_id, body, created_at, updated_at, edited"; | |
| 56 | +const LABEL_COLS: &str = "l.id, l.repo_id, l.name, l.color, l.description"; | |
| 57 | + | |
| 58 | +/// Caps the number of placeholders in one `IN (...)` list. | |
| 59 | +const IN_CHUNK: usize = 500; | |
| 60 | + | |
| 61 | +pub(super) fn parse_db<T: std::str::FromStr<Err = Error>>(s: &str) -> Result<T> { | |
| 62 | + s.parse::<T>() | |
| 63 | + .map_err(|e| Error::Database(format!("stored value {s:?}: {e}"))) | |
| 64 | +} | |
| 65 | + | |
| 66 | +/// Reads `ISSUE_COLS` (columns 0 to 14). | |
| 67 | +pub(super) fn map_issue(row: &Row) -> Result<Issue> { | |
| 68 | + Ok(Issue { | |
| 69 | + id: col_text(row, 0)?, | |
| 70 | + repo_id: col_text(row, 1)?, | |
| 71 | + number: col_int(row, 2)?, | |
| 72 | + title: col_text(row, 3)?, | |
| 73 | + body: col_text(row, 4)?, | |
| 74 | + state: parse_db(&col_text(row, 5)?)?, | |
| 75 | + state_reason: match col_opt_text(row, 6)? { | |
| 76 | + Some(r) => Some(parse_db::<StateReason>(&r)?), | |
| 77 | + None => None, | |
| 78 | + }, | |
| 79 | + author_id: col_text(row, 7)?, | |
| 80 | + milestone_id: col_opt_text(row, 8)?, | |
| 81 | + comment_count: col_int(row, 9)?, | |
| 82 | + created_at: col_text(row, 10)?, | |
| 83 | + updated_at: col_text(row, 11)?, | |
| 84 | + closed_at: col_opt_text(row, 12)?, | |
| 85 | + locked: col_int(row, 13)? != 0, | |
| 86 | + lock_reason: col_opt_text(row, 14)?, | |
| 87 | + }) | |
| 88 | +} | |
| 89 | + | |
| 90 | +fn map_comment(row: &Row) -> Result<Comment> { | |
| 91 | + Ok(Comment { | |
| 92 | + id: col_text(row, 0)?, | |
| 93 | + issue_id: col_text(row, 1)?, | |
| 94 | + author_id: col_text(row, 2)?, | |
| 95 | + body: col_text(row, 3)?, | |
| 96 | + created_at: col_text(row, 4)?, | |
| 97 | + updated_at: col_text(row, 5)?, | |
| 98 | + edited: col_int(row, 6)? != 0, | |
| 99 | + }) | |
| 100 | +} | |
| 101 | + | |
| 102 | +fn map_event(row: &Row) -> Result<IssueEvent> { | |
| 103 | + let raw = col_text(row, 4)?; | |
| 104 | + let data = serde_json::from_str(&raw) | |
| 105 | + .map_err(|e| Error::Database(format!("event data is not JSON: {e}")))?; | |
| 106 | + Ok(IssueEvent { | |
| 107 | + id: col_text(row, 0)?, | |
| 108 | + issue_id: col_text(row, 1)?, | |
| 109 | + actor_id: col_opt_text(row, 2)?, | |
| 110 | + kind: col_text(row, 3)?, | |
| 111 | + data, | |
| 112 | + created_at: col_text(row, 5)?, | |
| 113 | + }) | |
| 114 | +} | |
| 115 | + | |
| 116 | +/// A label read from `LABEL_COLS` (or any five columns) starting at `base`. | |
| 117 | +fn map_label(row: &Row, base: usize) -> Result<Label> { | |
| 118 | + Ok(Label { | |
| 119 | + id: col_text(row, base)?, | |
| 120 | + repo_id: col_text(row, base + 1)?, | |
| 121 | + name: col_text(row, base + 2)?, | |
| 122 | + color: col_text(row, base + 3)?, | |
| 123 | + description: col_text(row, base + 4)?, | |
| 124 | + }) | |
| 125 | +} | |
| 126 | + | |
| 127 | +fn placeholders(n: usize) -> String { | |
| 128 | + vec!["?"; n].join(",") | |
| 129 | +} | |
| 130 | + | |
| 131 | +pub(crate) fn like_pattern(query: &str) -> String { | |
| 132 | + let mut out = String::with_capacity(query.len() + 2); | |
| 133 | + out.push('%'); | |
| 134 | + for c in query.chars() { | |
| 135 | + if matches!(c, '\\' | '%' | '_') { | |
| 136 | + out.push('\\'); | |
| 137 | + } | |
| 138 | + out.push(c); | |
| 139 | + } | |
| 140 | + out.push('%'); | |
| 141 | + out | |
| 142 | +} | |
| 143 | + | |
| 144 | +/// Validates a `YYYY-MM-DD` due date. | |
| 145 | +pub fn validate_due_on(due_on: &str) -> Result<()> { | |
| 146 | + let format = time::macros::format_description!("[year]-[month]-[day]"); | |
| 147 | + match time::Date::parse(due_on, &format) { | |
| 148 | + Ok(_) => Ok(()), | |
| 149 | + Err(_) => Err(Error::Invalid(format!( | |
| 150 | + "due date {due_on:?} is not YYYY-MM-DD" | |
| 151 | + ))), | |
| 152 | + } | |
| 153 | +} | |
| 154 | + | |
| 155 | +/// Runs `outcome` inside `tx`: commits on success, rolls back on error. | |
| 156 | +pub(super) async fn finish<T>(tx: Transaction<'_>, outcome: Result<T>) -> Result<T> { | |
| 157 | + match outcome { | |
| 158 | + Ok(value) => { | |
| 159 | + tx.commit().await.map_err(db_err)?; | |
| 160 | + Ok(value) | |
| 161 | + } | |
| 162 | + Err(e) => { | |
| 163 | + let _ = tx.rollback().await; | |
| 164 | + Err(e) | |
| 165 | + } | |
| 166 | + } | |
| 167 | +} | |
| 168 | + | |
| 169 | +/// Inserts a timeline event. `data` must be a JSON object or null (stored as `{}`). | |
| 170 | +pub(super) async fn insert_event( | |
| 171 | + conn: &Connection, | |
| 172 | + issue_id: &str, | |
| 173 | + actor_id: Option<&str>, | |
| 174 | + kind: &str, | |
| 175 | + data: &Json, | |
| 176 | +) -> Result<()> { | |
| 177 | + conn.execute( | |
| 178 | + "INSERT INTO issue_events (id, issue_id, actor_id, kind, data, created_at) \ | |
| 179 | + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", | |
| 180 | + ( | |
| 181 | + new_id(), | |
| 182 | + issue_id.to_string(), | |
| 183 | + actor_id.map(str::to_string), | |
| 184 | + kind.to_string(), | |
| 185 | + data.to_string(), | |
| 186 | + now_stamp(), | |
| 187 | + ), | |
| 188 | + ) | |
| 189 | + .await | |
| 190 | + .map_err(db_err)?; | |
| 191 | + Ok(()) | |
| 192 | +} | |
| 193 | + | |
| 194 | +/// The issue or pull request row by id, or `NotFound`. Pull requests share the issue write paths | |
| 195 | +/// (comments, edits, state, events), so both kinds are accepted here. Lookups by number stay | |
| 196 | +/// kind-specific. | |
| 197 | +pub(super) async fn load_issue(conn: &Connection, issue_id: &str) -> Result<Issue> { | |
| 198 | + fetch_opt( | |
| 199 | + conn, | |
| 200 | + &format!("SELECT {ISSUE_COLS} FROM issues i WHERE i.id = ?1 AND i.kind IN ('issue', 'pull') LIMIT 1"), | |
| 201 | + (issue_id.to_string(),), | |
| 202 | + map_issue, | |
| 203 | + ) | |
| 204 | + .await? | |
| 205 | + .ok_or(Error::NotFound) | |
| 206 | +} | |
| 207 | + | |
| 208 | +/// Labels of an issue, read through `issue_labels`, keyed by issue id. | |
| 209 | +async fn labels_for_issues( | |
| 210 | + conn: &Connection, | |
| 211 | + issue_ids: &[String], | |
| 212 | +) -> Result<HashMap<String, Vec<Label>>> { | |
| 213 | + let mut out: HashMap<String, Vec<Label>> = HashMap::new(); | |
| 214 | + for chunk in issue_ids.chunks(IN_CHUNK) { | |
| 215 | + let sql = format!( | |
| 216 | + "SELECT il.issue_id, {LABEL_COLS} FROM issue_labels il \ | |
| 217 | + JOIN labels l ON l.id = il.label_id WHERE il.issue_id IN ({}) \ | |
| 218 | + ORDER BY l.name_folded", | |
| 219 | + placeholders(chunk.len()) | |
| 220 | + ); | |
| 221 | + let rows = fetch_all(conn, &sql, chunk.to_vec(), |row| { | |
| 222 | + Ok((col_text(row, 0)?, map_label(row, 1)?)) | |
| 223 | + }) | |
| 224 | + .await?; | |
| 225 | + for (issue_id, label) in rows { | |
| 226 | + out.entry(issue_id).or_default().push(label); | |
| 227 | + } | |
| 228 | + } | |
| 229 | + Ok(out) | |
| 230 | +} | |
| 231 | + | |
| 232 | +/// Display usernames of assignees, keyed by issue id. | |
| 233 | +async fn assignees_for_issues( | |
| 234 | + conn: &Connection, | |
| 235 | + issue_ids: &[String], | |
| 236 | +) -> Result<HashMap<String, Vec<String>>> { | |
| 237 | + let mut out: HashMap<String, Vec<String>> = HashMap::new(); | |
| 238 | + for chunk in issue_ids.chunks(IN_CHUNK) { | |
| 239 | + let sql = format!( | |
| 240 | + "SELECT ia.issue_id, u.username FROM issue_assignees ia \ | |
| 241 | + JOIN users u ON u.id = ia.user_id WHERE ia.issue_id IN ({}) \ | |
| 242 | + ORDER BY u.username_folded", | |
| 243 | + placeholders(chunk.len()) | |
| 244 | + ); | |
| 245 | + let rows = fetch_all(conn, &sql, chunk.to_vec(), |row| { | |
| 246 | + Ok((col_text(row, 0)?, col_text(row, 1)?)) | |
| 247 | + }) | |
| 248 | + .await?; | |
| 249 | + for (issue_id, name) in rows { | |
| 250 | + out.entry(issue_id).or_default().push(name); | |
| 251 | + } | |
| 252 | + } | |
| 253 | + Ok(out) | |
| 254 | +} | |
| 255 | + | |
| 256 | +// issues | |
| 257 | +impl Store { | |
| 258 | + /// Creates an issue with the next number of the repo. The read of the counter, the insert | |
| 259 | + /// and the increment run in one transaction, so numbers are unique and never reused. | |
| 260 | + pub async fn create_issue( | |
| 261 | + &self, | |
| 262 | + repo_id: &str, | |
| 263 | + author_id: &str, | |
| 264 | + title: &str, | |
| 265 | + body: &str, | |
| 266 | + ) -> Result<Issue> { | |
| 267 | + let title = validate_title(title)?; | |
| 268 | + validate_body(body)?; | |
| 269 | + let conn = self.inner.conn.lock().await; | |
| 270 | + let tx = conn.unchecked_transaction().await.map_err(db_err)?; | |
| 271 | + let outcome: Result<Issue> = async { | |
| 272 | + let number = fetch_opt( | |
| 273 | + &tx, | |
| 274 | + "SELECT next_issue_number FROM repos WHERE id = ?1 LIMIT 1", | |
| 275 | + (repo_id.to_string(),), | |
| 276 | + |row| col_int(row, 0), | |
| 277 | + ) | |
| 278 | + .await? | |
| 279 | + .ok_or(Error::NotFound)?; | |
| 280 | + let now = now_stamp(); | |
| 281 | + let id = new_id(); | |
| 282 | + tx.execute( | |
| 283 | + "INSERT INTO issues (id, repo_id, number, kind, title, body, state, author_id, \ | |
| 284 | + comment_count, created_at, updated_at) \ | |
| 285 | + VALUES (?1, ?2, ?3, 'issue', ?4, ?5, 'open', ?6, 0, ?7, ?7)", | |
| 286 | + ( | |
| 287 | + id.clone(), | |
| 288 | + repo_id.to_string(), | |
| 289 | + number, | |
| 290 | + title.clone(), | |
| 291 | + body.to_string(), | |
| 292 | + author_id.to_string(), | |
| 293 | + now.clone(), | |
| 294 | + ), | |
| 295 | + ) | |
| 296 | + .await | |
| 297 | + .map_err(|e| insert_err(e, "issue number already taken"))?; | |
| 298 | + tx.execute( | |
| 299 | + "UPDATE repos SET next_issue_number = next_issue_number + 1 WHERE id = ?1", | |
| 300 | + (repo_id.to_string(),), | |
| 301 | + ) | |
| 302 | + .await | |
| 303 | + .map_err(db_err)?; | |
| 304 | + Ok(Issue { | |
| 305 | + id, | |
| 306 | + repo_id: repo_id.to_string(), | |
| 307 | + number, | |
| 308 | + title, | |
| 309 | + body: body.to_string(), | |
| 310 | + state: IssueState::Open, | |
| 311 | + state_reason: None, | |
| 312 | + author_id: author_id.to_string(), | |
| 313 | + milestone_id: None, | |
| 314 | + comment_count: 0, | |
| 315 | + created_at: now.clone(), | |
| 316 | + updated_at: now, | |
| 317 | + closed_at: None, | |
| 318 | + locked: false, | |
| 319 | + lock_reason: None, | |
| 320 | + }) | |
| 321 | + } | |
| 322 | + .await; | |
| 323 | + finish(tx, outcome).await | |
| 324 | + } | |
| 325 | + | |
| 326 | + pub async fn issue_by_number(&self, repo_id: &str, number: i64) -> Result<Option<Issue>> { | |
| 327 | + let conn = self.read_conn().await; | |
| 328 | + fetch_opt( | |
| 329 | + &conn, | |
| 330 | + &format!( | |
| 331 | + "SELECT {ISSUE_COLS} FROM issues i WHERE i.repo_id = ?1 AND i.number = ?2 \ | |
| 332 | + AND i.kind = 'issue' LIMIT 1" | |
| 333 | + ), | |
| 334 | + (repo_id.to_string(), number), | |
| 335 | + map_issue, | |
| 336 | + ) | |
| 337 | + .await | |
| 338 | + } | |
| 339 | + | |
| 340 | + /// One page of issues matching `f`, plus the total number of matches. | |
| 341 | + /// Every user value is a bound parameter. The clauses and the ORDER BY are fixed strings. | |
| 342 | + pub async fn list_issues( | |
| 343 | + &self, | |
| 344 | + repo_id: &str, | |
| 345 | + f: &crate::issues::IssueFilter, | |
| 346 | + ) -> Result<(Vec<IssueListItem>, u64)> { | |
| 347 | + self.list_of_kind(repo_id, f, "issue").await | |
| 348 | + } | |
| 349 | + | |
| 350 | + /// `list_issues` for any `kind` ('issue' or 'pull'). Pull request listing shares it. | |
| 351 | + pub(super) async fn list_of_kind( | |
| 352 | + &self, | |
| 353 | + repo_id: &str, | |
| 354 | + f: &crate::issues::IssueFilter, | |
| 355 | + kind: &str, | |
| 356 | + ) -> Result<(Vec<IssueListItem>, u64)> { | |
| 357 | + let mut clauses: Vec<&str> = vec!["i.repo_id = ?", "i.kind = ?"]; | |
| 358 | + let mut params: Vec<Value> = vec![ | |
| 359 | + Value::Text(repo_id.to_string()), | |
| 360 | + Value::Text(kind.to_string()), | |
| 361 | + ]; | |
| 362 | + | |
| 363 | + if let Some(state) = f.state { | |
| 364 | + clauses.push("i.state = ?"); | |
| 365 | + params.push(Value::Text(state.as_str().to_string())); | |
| 366 | + } | |
| 367 | + if let Some(label) = non_empty(f.label.as_deref()) { | |
| 368 | + clauses.push( | |
| 369 | + "EXISTS (SELECT 1 FROM issue_labels il JOIN labels l ON l.id = il.label_id \ | |
| 370 | + WHERE il.issue_id = i.id AND l.repo_id = i.repo_id AND l.name_folded = ?)", | |
| 371 | + ); | |
| 372 | + params.push(Value::Text(normalize_name(label))); | |
| 373 | + } | |
| 374 | + if let Some(milestone) = non_empty(f.milestone.as_deref()) { | |
| 375 | + clauses.push( | |
| 376 | + "EXISTS (SELECT 1 FROM milestones m WHERE m.id = i.milestone_id \ | |
| 377 | + AND m.repo_id = i.repo_id AND m.title_folded = ?)", | |
| 378 | + ); | |
| 379 | + params.push(Value::Text(normalize_name(milestone))); | |
| 380 | + } | |
| 381 | + if let Some(author) = non_empty(f.author.as_deref()) { | |
| 382 | + clauses.push("u.username_folded = ?"); | |
| 383 | + params.push(Value::Text(normalize_name(author))); | |
| 384 | + } | |
| 385 | + if let Some(assignee) = non_empty(f.assignee.as_deref()) { | |
| 386 | + clauses.push( | |
| 387 | + "EXISTS (SELECT 1 FROM issue_assignees ia JOIN users ua ON ua.id = ia.user_id \ | |
| 388 | + WHERE ia.issue_id = i.id AND ua.username_folded = ?)", | |
| 389 | + ); | |
| 390 | + params.push(Value::Text(normalize_name(assignee))); | |
| 391 | + } | |
| 392 | + if let Some(query) = non_empty(f.query.as_deref()) { | |
| 393 | + // Escape the LIKE wildcards and the escape character itself. | |
| 394 | + clauses.push("i.title LIKE ? ESCAPE '\\'"); | |
| 395 | + params.push(Value::Text(like_pattern(query))); | |
| 396 | + } | |
| 397 | + let where_sql = clauses.join(" AND "); | |
| 398 | + let order = match f.sort { | |
| 399 | + IssueSort::Newest => "i.number DESC", | |
| 400 | + IssueSort::Oldest => "i.number ASC", | |
| 401 | + IssueSort::RecentlyUpdated => "i.updated_at DESC, i.number DESC", | |
| 402 | + }; | |
| 403 | + let per_page = f.per_page.clamp(1, 100); | |
| 404 | + let page = f.page.clamp(1, 1000); | |
| 405 | + let offset = i64::from(page - 1) * i64::from(per_page); | |
| 406 | + | |
| 407 | + let conn = self.read_conn().await; | |
| 408 | + let total = fetch_opt( | |
| 409 | + &conn, | |
| 410 | + &format!( | |
| 411 | + "SELECT COUNT(*) FROM issues i JOIN users u ON u.id = i.author_id WHERE {where_sql}" | |
| 412 | + ), | |
| 413 | + params.clone(), | |
| 414 | + |row| col_int(row, 0), | |
| 415 | + ) | |
| 416 | + .await? | |
| 417 | + .unwrap_or(0); | |
| 418 | + let total = u64::try_from(total).unwrap_or(0); | |
| 419 | + | |
| 420 | + let mut page_params = params; | |
| 421 | + page_params.push(Value::Integer(i64::from(per_page))); | |
| 422 | + page_params.push(Value::Integer(offset)); | |
| 423 | + let mut items = fetch_all( | |
| 424 | + &conn, | |
| 425 | + &format!( | |
| 426 | + "SELECT {ISSUE_COLS}, u.username FROM issues i JOIN users u ON u.id = i.author_id \ | |
| 427 | + WHERE {where_sql} ORDER BY {order} LIMIT ? OFFSET ?" | |
| 428 | + ), | |
| 429 | + page_params, | |
| 430 | + |row| { | |
| 431 | + Ok(IssueListItem { | |
| 432 | + issue: map_issue(row)?, | |
| 433 | + author: col_text(row, 15)?, | |
| 434 | + labels: Vec::new(), | |
| 435 | + assignees: Vec::new(), | |
| 436 | + }) | |
| 437 | + }, | |
| 438 | + ) | |
| 439 | + .await?; | |
| 440 | + | |
| 441 | + let ids: Vec<String> = items.iter().map(|item| item.issue.id.clone()).collect(); | |
| 442 | + let mut labels = labels_for_issues(&conn, &ids).await?; | |
| 443 | + let mut assignees = assignees_for_issues(&conn, &ids).await?; | |
| 444 | + for item in &mut items { | |
| 445 | + item.labels = labels.remove(&item.issue.id).unwrap_or_default(); | |
| 446 | + item.assignees = assignees.remove(&item.issue.id).unwrap_or_default(); | |
| 447 | + } | |
| 448 | + Ok((items, total)) | |
| 449 | + } | |
| 450 | + | |
| 451 | + /// Open and closed issue counts of the repo (issues only, not pulls). | |
| 452 | + pub async fn issue_counts(&self, repo_id: &str) -> Result<(u64, u64)> { | |
| 453 | + self.counts_of_kind(repo_id, "issue").await | |
| 454 | + } | |
| 455 | + | |
| 456 | + /// Open and closed counts for one `kind` ('issue' or 'pull'). | |
| 457 | + pub(super) async fn counts_of_kind(&self, repo_id: &str, kind: &str) -> Result<(u64, u64)> { | |
| 458 | + let conn = self.read_conn().await; | |
| 459 | + let row = fetch_opt( | |
| 460 | + &conn, | |
| 461 | + "SELECT COUNT(CASE WHEN state = 'open' THEN 1 END), \ | |
| 462 | + COUNT(CASE WHEN state = 'closed' THEN 1 END) \ | |
| 463 | + FROM issues WHERE repo_id = ?1 AND kind = ?2", | |
| 464 | + (repo_id.to_string(), kind.to_string()), | |
| 465 | + |row| Ok((col_int(row, 0)?, col_int(row, 1)?)), | |
| 466 | + ) | |
| 467 | + .await? | |
| 468 | + .ok_or_else(missing_row)?; | |
| 469 | + Ok(( | |
| 470 | + u64::try_from(row.0).unwrap_or(0), | |
| 471 | + u64::try_from(row.1).unwrap_or(0), | |
| 472 | + )) | |
| 473 | + } | |
| 474 | + | |
| 475 | + /// Saves the previous title and body to the edit history. A title change also records a | |
| 476 | + /// `renamed` event with `{"from", "to"}`. | |
| 477 | + pub async fn edit_issue( | |
| 478 | + &self, | |
| 479 | + issue_id: &str, | |
| 480 | + editor_id: &str, | |
| 481 | + title: &str, | |
| 482 | + body: &str, | |
| 483 | + ) -> Result<()> { | |
| 484 | + let title = validate_title(title)?; | |
| 485 | + validate_body(body)?; | |
| 486 | + let conn = self.inner.conn.lock().await; | |
| 487 | + let tx = conn.unchecked_transaction().await.map_err(db_err)?; | |
| 488 | + let outcome: Result<()> = async { | |
| 489 | + let current = load_issue(&tx, issue_id).await?; | |
| 490 | + let now = now_stamp(); | |
| 491 | + tx.execute( | |
| 492 | + "INSERT INTO edit_history (id, target_kind, target_id, old_title, old_body, editor_id, edited_at) \ | |
| 493 | + VALUES (?1, 'issue', ?2, ?3, ?4, ?5, ?6)", | |
| 494 | + ( | |
| 495 | + new_id(), | |
| 496 | + issue_id.to_string(), | |
| 497 | + current.title.clone(), | |
| 498 | + current.body.clone(), | |
| 499 | + editor_id.to_string(), | |
| 500 | + now.clone(), | |
| 501 | + ), | |
| 502 | + ) | |
| 503 | + .await | |
| 504 | + .map_err(db_err)?; | |
| 505 | + tx.execute( | |
| 506 | + "UPDATE issues SET title = ?2, body = ?3, updated_at = ?4 WHERE id = ?1", | |
| 507 | + ( | |
| 508 | + issue_id.to_string(), | |
| 509 | + title.clone(), | |
| 510 | + body.to_string(), | |
| 511 | + now, | |
| 512 | + ), | |
| 513 | + ) | |
| 514 | + .await | |
| 515 | + .map_err(db_err)?; | |
| 516 | + if current.title != title { | |
| 517 | + let data = serde_json::json!({ "from": current.title, "to": title }); | |
| 518 | + insert_event(&tx, issue_id, Some(editor_id), "renamed", &data).await?; | |
| 519 | + } | |
| 520 | + Ok(()) | |
| 521 | + } | |
| 522 | + .await; | |
| 523 | + finish(tx, outcome).await | |
| 524 | + } | |
| 525 | + | |
| 526 | + /// Sets the state. Returns `false` and changes nothing if the issue is already in `state`. | |
| 527 | + /// Closing sets `closed_at` and `state_reason`; reopening clears both. The closed or | |
| 528 | + /// reopened event carries `data`, plus `"reason"` when closing with one. | |
| 529 | + pub async fn set_issue_state( | |
| 530 | + &self, | |
| 531 | + issue_id: &str, | |
| 532 | + actor_id: Option<&str>, | |
| 533 | + state: IssueState, | |
| 534 | + reason: Option<StateReason>, | |
| 535 | + data: Json, | |
| 536 | + ) -> Result<bool> { | |
| 537 | + let mut data = match data { | |
| 538 | + Json::Object(map) => map, | |
| 539 | + Json::Null => Map::new(), | |
| 540 | + _ => { | |
| 541 | + return Err(Error::Invalid( | |
| 542 | + "event data must be a JSON object".to_string(), | |
| 543 | + )); | |
| 544 | + } | |
| 545 | + }; | |
| 546 | + let conn = self.inner.conn.lock().await; | |
| 547 | + let tx = conn.unchecked_transaction().await.map_err(db_err)?; | |
| 548 | + let outcome: Result<bool> = async { | |
| 549 | + let current = load_issue(&tx, issue_id).await?; | |
| 550 | + if current.state == state { | |
| 551 | + return Ok(false); | |
| 552 | + } | |
| 553 | + let now = now_stamp(); | |
| 554 | + let (reason_value, closed_at, kind) = match state { | |
| 555 | + IssueState::Open => (None, None, "reopened"), | |
| 556 | + IssueState::Closed => { | |
| 557 | + if let Some(r) = reason { | |
| 558 | + data.insert("reason".into(), Json::from(r.as_str())); | |
| 559 | + } | |
| 560 | + ( | |
| 561 | + reason.map(|r| r.as_str().to_string()), | |
| 562 | + Some(now.clone()), | |
| 563 | + "closed", | |
| 564 | + ) | |
| 565 | + } | |
| 566 | + }; | |
| 567 | + tx.execute( | |
| 568 | + "UPDATE issues SET state = ?2, state_reason = ?3, closed_at = ?4, updated_at = ?5 \ | |
| 569 | + WHERE id = ?1", | |
| 570 | + ( | |
| 571 | + issue_id.to_string(), | |
| 572 | + state.as_str().to_string(), | |
| 573 | + reason_value, | |
| 574 | + closed_at, | |
| 575 | + now, | |
| 576 | + ), | |
| 577 | + ) | |
| 578 | + .await | |
| 579 | + .map_err(db_err)?; | |
| 580 | + insert_event(&tx, issue_id, actor_id, kind, &Json::Object(data)).await?; | |
| 581 | + Ok(true) | |
| 582 | + } | |
| 583 | + .await; | |
| 584 | + finish(tx, outcome).await | |
| 585 | + } | |
| 586 | + | |
| 587 | + /// Locks or unlocks a conversation. `reason` must be one of `LOCK_REASONS` or `None`, and is | |
| 588 | + /// dropped on unlock. Returns `false` and changes nothing when the lock state and reason | |
| 589 | + /// already match. The `locked` or `unlocked` event carries `"reason"` when one is given. | |
| 590 | + pub async fn set_issue_locked( | |
| 591 | + &self, | |
| 592 | + issue_id: &str, | |
| 593 | + actor_id: Option<&str>, | |
| 594 | + locked: bool, | |
| 595 | + reason: Option<&str>, | |
| 596 | + ) -> Result<bool> { | |
| 597 | + let reason = match reason { | |
| 598 | + Some(r) if !r.is_empty() => { | |
| 599 | + if !LOCK_REASONS.contains(&r) { | |
| 600 | + return Err(Error::Invalid(format!("lock reason {r:?}"))); | |
| 601 | + } | |
| 602 | + Some(r.to_string()) | |
| 603 | + } | |
| 604 | + _ => None, | |
| 605 | + }; | |
| 606 | + let reason = if locked { reason } else { None }; | |
| 607 | + let conn = self.inner.conn.lock().await; | |
| 608 | + let tx = conn.unchecked_transaction().await.map_err(db_err)?; | |
| 609 | + let outcome: Result<bool> = async { | |
| 610 | + let current = load_issue(&tx, issue_id).await?; | |
| 611 | + if current.locked == locked && current.lock_reason == reason { | |
| 612 | + return Ok(false); | |
| 613 | + } | |
| 614 | + tx.execute( | |
| 615 | + "UPDATE issues SET locked = ?2, lock_reason = ?3 WHERE id = ?1", | |
| 616 | + (issue_id.to_string(), i64::from(locked), reason.clone()), | |
| 617 | + ) | |
| 618 | + .await | |
| 619 | + .map_err(db_err)?; | |
| 620 | + let mut data = Map::new(); | |
| 621 | + let kind = if locked { | |
| 622 | + if let Some(r) = &reason { | |
| 623 | + data.insert("reason".into(), Json::from(r.as_str())); | |
| 624 | + } | |
| 625 | + "locked" | |
| 626 | + } else { | |
| 627 | + "unlocked" | |
| 628 | + }; | |
| 629 | + insert_event(&tx, issue_id, actor_id, kind, &Json::Object(data)).await?; | |
| 630 | + Ok(true) | |
| 631 | + } | |
| 632 | + .await; | |
| 633 | + finish(tx, outcome).await | |
| 634 | + } | |
| 635 | + | |
| 636 | + // comments | |
| 637 | + | |
| 638 | + /// Adds a comment, bumps the issue's `comment_count` and `updated_at`. Blank bodies are | |
| 639 | + /// rejected. | |
| 640 | + pub async fn add_comment( | |
| 641 | + &self, | |
| 642 | + issue_id: &str, | |
| 643 | + author_id: &str, | |
| 644 | + body: &str, | |
| 645 | + ) -> Result<Comment> { | |
| 646 | + validate_body(body)?; | |
| 647 | + if body.trim().is_empty() { | |
| 648 | + return Err(Error::Invalid("comment is empty".into())); | |
| 649 | + } | |
| 650 | + let conn = self.inner.conn.lock().await; | |
| 651 | + let tx = conn.unchecked_transaction().await.map_err(db_err)?; | |
| 652 | + let outcome: Result<Comment> = async { | |
| 653 | + load_issue(&tx, issue_id).await?; | |
| 654 | + let now = now_stamp(); | |
| 655 | + let comment = Comment { | |
| 656 | + id: new_id(), | |
| 657 | + issue_id: issue_id.to_string(), | |
| 658 | + author_id: author_id.to_string(), | |
| 659 | + body: body.to_string(), | |
| 660 | + created_at: now.clone(), | |
| 661 | + updated_at: now.clone(), | |
| 662 | + edited: false, | |
| 663 | + }; | |
| 664 | + tx.execute( | |
| 665 | + "INSERT INTO comments (id, issue_id, author_id, body, created_at, updated_at, edited) \ | |
| 666 | + VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0)", | |
| 667 | + ( | |
| 668 | + comment.id.clone(), | |
| 669 | + comment.issue_id.clone(), | |
| 670 | + comment.author_id.clone(), | |
| 671 | + comment.body.clone(), | |
| 672 | + comment.created_at.clone(), | |
| 673 | + comment.updated_at.clone(), | |
| 674 | + ), | |
| 675 | + ) | |
| 676 | + .await | |
| 677 | + .map_err(db_err)?; | |
| 678 | + tx.execute( | |
| 679 | + "UPDATE issues SET comment_count = comment_count + 1, updated_at = ?2 WHERE id = ?1", | |
| 680 | + (issue_id.to_string(), now), | |
| 681 | + ) | |
| 682 | + .await | |
| 683 | + .map_err(db_err)?; | |
| 684 | + Ok(comment) | |
| 685 | + } | |
| 686 | + .await; | |
| 687 | + finish(tx, outcome).await | |
| 688 | + } | |
| 689 | + | |
| 690 | + pub async fn comment_by_id(&self, issue_id: &str, comment_id: &str) -> Result<Option<Comment>> { | |
| 691 | + let conn = self.read_conn().await; | |
| 692 | + fetch_opt( | |
| 693 | + &conn, | |
| 694 | + &format!("SELECT {COMMENT_COLS} FROM comments WHERE id = ?1 AND issue_id = ?2 LIMIT 1"), | |
| 695 | + (comment_id.to_string(), issue_id.to_string()), | |
| 696 | + map_comment, | |
| 697 | + ) | |
| 698 | + .await | |
| 699 | + } | |
| 700 | + | |
| 701 | + /// Saves the previous body to the edit history and marks the comment edited. | |
| 702 | + pub async fn edit_comment(&self, comment_id: &str, editor_id: &str, body: &str) -> Result<()> { | |
| 703 | + validate_body(body)?; | |
| 704 | + if body.trim().is_empty() { | |
| 705 | + return Err(Error::Invalid("comment is empty".into())); | |
| 706 | + } | |
| 707 | + let conn = self.inner.conn.lock().await; | |
| 708 | + let tx = conn.unchecked_transaction().await.map_err(db_err)?; | |
| 709 | + let outcome: Result<()> = async { | |
| 710 | + let current = fetch_opt( | |
| 711 | + &tx, | |
| 712 | + &format!("SELECT {COMMENT_COLS} FROM comments WHERE id = ?1 LIMIT 1"), | |
| 713 | + (comment_id.to_string(),), | |
| 714 | + map_comment, | |
| 715 | + ) | |
| 716 | + .await? | |
| 717 | + .ok_or(Error::NotFound)?; | |
| 718 | + let now = now_stamp(); | |
| 719 | + tx.execute( | |
| 720 | + "INSERT INTO edit_history (id, target_kind, target_id, old_title, old_body, editor_id, edited_at) \ | |
| 721 | + VALUES (?1, 'comment', ?2, NULL, ?3, ?4, ?5)", | |
| 722 | + ( | |
| 723 | + new_id(), | |
| 724 | + comment_id.to_string(), | |
| 725 | + current.body, | |
| 726 | + editor_id.to_string(), | |
| 727 | + now.clone(), | |
| 728 | + ), | |
| 729 | + ) | |
| 730 | + .await | |
| 731 | + .map_err(db_err)?; | |
| 732 | + tx.execute( | |
| 733 | + "UPDATE comments SET body = ?2, edited = 1, updated_at = ?3 WHERE id = ?1", | |
| 734 | + (comment_id.to_string(), body.to_string(), now), | |
| 735 | + ) | |
| 736 | + .await | |
| 737 | + .map_err(db_err)?; | |
| 738 | + Ok(()) | |
| 739 | + } | |
| 740 | + .await; | |
| 741 | + finish(tx, outcome).await | |
| 742 | + } | |
| 743 | + | |
| 744 | + /// Deletes the comment with its edit history and decrements the issue's `comment_count`. | |
| 745 | + pub async fn delete_comment(&self, comment_id: &str) -> Result<()> { | |
| 746 | + let conn = self.inner.conn.lock().await; | |
| 747 | + let tx = conn.unchecked_transaction().await.map_err(db_err)?; | |
| 748 | + let outcome: Result<()> = async { | |
| 749 | + let issue_id = fetch_opt( | |
| 750 | + &tx, | |
| 751 | + "SELECT issue_id FROM comments WHERE id = ?1 LIMIT 1", | |
| 752 | + (comment_id.to_string(),), | |
| 753 | + |row| col_text(row, 0), | |
| 754 | + ) | |
| 755 | + .await? | |
| 756 | + .ok_or(Error::NotFound)?; | |
| 757 | + tx.execute( | |
| 758 | + "DELETE FROM edit_history WHERE target_kind = 'comment' AND target_id = ?1", | |
| 759 | + (comment_id.to_string(),), | |
| 760 | + ) | |
| 761 | + .await | |
| 762 | + .map_err(db_err)?; | |
| 763 | + tx.execute( | |
| 764 | + "DELETE FROM reactions WHERE target_kind = 'comment' AND target_id = ?1", | |
| 765 | + (comment_id.to_string(),), | |
| 766 | + ) | |
| 767 | + .await | |
| 768 | + .map_err(db_err)?; | |
| 769 | + tx.execute( | |
| 770 | + "DELETE FROM comments WHERE id = ?1", | |
| 771 | + (comment_id.to_string(),), | |
| 772 | + ) | |
| 773 | + .await | |
| 774 | + .map_err(db_err)?; | |
| 775 | + tx.execute( | |
| 776 | + "UPDATE issues SET comment_count = comment_count - 1 WHERE id = ?1 AND comment_count > 0", | |
| 777 | + (issue_id,), | |
| 778 | + ) | |
| 779 | + .await | |
| 780 | + .map_err(db_err)?; | |
| 781 | + Ok(()) | |
| 782 | + } | |
| 783 | + .await; | |
| 784 | + finish(tx, outcome).await | |
| 785 | + } | |
| 786 | + | |
| 787 | + pub async fn list_comments(&self, issue_id: &str) -> Result<Vec<Comment>> { | |
| 788 | + let conn = self.read_conn().await; | |
| 789 | + fetch_all( | |
| 790 | + &conn, | |
| 791 | + &format!( | |
| 792 | + "SELECT {COMMENT_COLS} FROM comments WHERE issue_id = ?1 ORDER BY created_at, id" | |
| 793 | + ), | |
| 794 | + (issue_id.to_string(),), | |
| 795 | + map_comment, | |
| 796 | + ) | |
| 797 | + .await | |
| 798 | + } | |
| 799 | + | |
| 800 | + // events | |
| 801 | + | |
| 802 | + pub async fn list_events(&self, issue_id: &str) -> Result<Vec<IssueEvent>> { | |
| 803 | + let conn = self.read_conn().await; | |
| 804 | + fetch_all( | |
| 805 | + &conn, | |
| 806 | + "SELECT id, issue_id, actor_id, kind, data, created_at FROM issue_events \ | |
| 807 | + WHERE issue_id = ?1 ORDER BY created_at, id", | |
| 808 | + (issue_id.to_string(),), | |
| 809 | + map_event, | |
| 810 | + ) | |
| 811 | + .await | |
| 812 | + } | |
| 813 | + | |
| 814 | + /// Records a timeline event. `kind` must be one of the known event kinds, and `data` a JSON | |
| 815 | + /// object or null. | |
| 816 | + pub async fn add_event( | |
| 817 | + &self, | |
| 818 | + issue_id: &str, | |
| 819 | + actor_id: Option<&str>, | |
| 820 | + kind: &str, | |
| 821 | + data: Json, | |
| 822 | + ) -> Result<()> { | |
| 823 | + if !EVENT_KINDS.contains(&kind) { | |
| 824 | + return Err(Error::Invalid(format!("event kind {kind:?}"))); | |
| 825 | + } | |
| 826 | + let data = match data { | |
| 827 | + Json::Object(_) => data, | |
| 828 | + Json::Null => Json::Object(Map::new()), | |
| 829 | + _ => { | |
| 830 | + return Err(Error::Invalid( | |
| 831 | + "event data must be a JSON object".to_string(), | |
| 832 | + )); | |
| 833 | + } | |
| 834 | + }; | |
| 835 | + let conn = self.inner.conn.lock().await; | |
| 836 | + load_issue(&conn, issue_id).await?; | |
| 837 | + insert_event(&conn, issue_id, actor_id, kind, &data).await | |
| 838 | + } | |
| 839 | + | |
| 840 | + // users | |
| 841 | + | |
| 842 | + /// Display name and avatar hash for each id that exists. Unknown ids are left out. | |
| 843 | + pub async fn usernames_by_ids( | |
| 844 | + &self, | |
| 845 | + ids: &[String], | |
| 846 | + ) -> Result<HashMap<String, (String, Option<String>)>> { | |
| 847 | + let mut out = HashMap::new(); | |
| 848 | + if ids.is_empty() { | |
| 849 | + return Ok(out); | |
| 850 | + } | |
| 851 | + let conn = self.read_conn().await; | |
| 852 | + for chunk in ids.chunks(IN_CHUNK) { | |
| 853 | + let rows = fetch_all( | |
| 854 | + &conn, | |
| 855 | + &format!( | |
| 856 | + "SELECT u.id, u.username, (SELECT hash FROM avatars WHERE avatars.user_id = u.id) \ | |
| 857 | + FROM users u WHERE u.id IN ({})", | |
| 858 | + placeholders(chunk.len()) | |
| 859 | + ), | |
| 860 | + chunk.to_vec(), | |
| 861 | + |row| { | |
| 862 | + Ok(( | |
| 863 | + col_text(row, 0)?, | |
| 864 | + col_text(row, 1)?, | |
| 865 | + col_opt_text(row, 2)?, | |
| 866 | + )) | |
| 867 | + }, | |
| 868 | + ) | |
| 869 | + .await?; | |
| 870 | + for (id, name, avatar) in rows { | |
| 871 | + out.insert(id, (name, avatar)); | |
| 872 | + } | |
| 873 | + } | |
| 874 | + Ok(out) | |
| 875 | + } | |
| 876 | +} | |
| 877 | + | |
| 878 | +/// Repo deletion for the M3 tables. Called from `Store::delete_repo` inside its transaction. | |
| 879 | +pub(super) async fn delete_repo_issue_rows(tx: &Transaction<'_>, repo_id: &str) -> Result<()> { | |
| 880 | + let id = repo_id.to_string(); | |
| 881 | + for sql in [ | |
| 882 | + "DELETE FROM reactions WHERE \ | |
| 883 | + (target_kind = 'issue' AND target_id IN (SELECT id FROM issues WHERE repo_id = ?1)) \ | |
| 884 | + OR (target_kind = 'comment' AND target_id IN \ | |
| 885 | + (SELECT c.id FROM comments c JOIN issues i ON i.id = c.issue_id WHERE i.repo_id = ?1))", | |
| 886 | + "DELETE FROM issue_labels WHERE issue_id IN (SELECT id FROM issues WHERE repo_id = ?1)", | |
| 887 | + "DELETE FROM issue_assignees WHERE issue_id IN (SELECT id FROM issues WHERE repo_id = ?1)", | |
| 888 | + "DELETE FROM issue_events WHERE issue_id IN (SELECT id FROM issues WHERE repo_id = ?1)", | |
| 889 | + "DELETE FROM edit_history WHERE \ | |
| 890 | + (target_kind = 'issue' AND target_id IN (SELECT id FROM issues WHERE repo_id = ?1)) \ | |
| 891 | + OR (target_kind = 'comment' AND target_id IN \ | |
| 892 | + (SELECT c.id FROM comments c JOIN issues i ON i.id = c.issue_id WHERE i.repo_id = ?1))", | |
| 893 | + "DELETE FROM comments WHERE issue_id IN (SELECT id FROM issues WHERE repo_id = ?1)", | |
| 894 | + "DELETE FROM pinned_issues WHERE repo_id = ?1", | |
| 895 | + "DELETE FROM issues WHERE repo_id = ?1", | |
| 896 | + "DELETE FROM labels WHERE repo_id = ?1", | |
| 897 | + "DELETE FROM milestones WHERE repo_id = ?1", | |
| 898 | + ] { | |
| 899 | + tx.execute(sql, (id.clone(),)).await.map_err(db_err)?; | |
| 900 | + } | |
| 901 | + Ok(()) | |
| 902 | +} | |
| 903 | + | |
| 904 | +fn non_empty(value: Option<&str>) -> Option<&str> { | |
| 905 | + value.map(str::trim).filter(|s| !s.is_empty()) | |
| 906 | +} | |
| 907 | + | |
| 908 | +#[cfg(test)] | |
| 909 | +mod tests { | |
| 910 | + use super::*; | |
| 911 | + use crate::issues::{IssueFilter, IssueSort, MAX_BODY}; | |
| 912 | + use crate::model::{User, Visibility}; | |
| 913 | + | |
| 914 | + struct Fixture { | |
| 915 | + store: Store, | |
| 916 | + alice: User, | |
| 917 | + bob: User, | |
| 918 | + repo_id: String, | |
| 919 | + _dir: tempfile::TempDir, | |
| 920 | + } | |
| 921 | + | |
| 922 | + async fn fixture() -> Fixture { | |
| 923 | + let dir = tempfile::tempdir().unwrap(); | |
| 924 | + let store = Store::open(&dir.path().join("forge.db")).await.unwrap(); | |
| 925 | + let alice = store | |
| 926 | + .create_user("Alice", None, "hash", false) | |
| 927 | + .await | |
| 928 | + .unwrap(); | |
| 929 | + let bob = store.create_user("Bob", None, "hash", false).await.unwrap(); | |
| 930 | + let repo = store | |
| 931 | + .create_repo(&alice.id, "proj", "", Visibility::Public, "main") | |
| 932 | + .await | |
| 933 | + .unwrap(); | |
| 934 | + Fixture { | |
| 935 | + store, | |
| 936 | + alice, | |
| 937 | + bob, | |
| 938 | + repo_id: repo.id, | |
| 939 | + _dir: dir, | |
| 940 | + } | |
| 941 | + } | |
| 942 | + | |
| 943 | + fn all() -> IssueFilter { | |
| 944 | + IssueFilter { | |
| 945 | + state: None, | |
| 946 | + ..IssueFilter::default() | |
| 947 | + } | |
| 948 | + } | |
| 949 | + | |
| 950 | + async fn titles(store: &Store, repo_id: &str, f: &IssueFilter) -> Vec<String> { | |
| 951 | + let (items, _) = store.list_issues(repo_id, f).await.unwrap(); | |
| 952 | + items.into_iter().map(|i| i.issue.title).collect() | |
| 953 | + } | |
| 954 | + | |
| 955 | + #[tokio::test] | |
| 956 | + async fn create_issue_numbers_start_at_one_and_advance() { | |
| 957 | + let fx = fixture().await; | |
| 958 | + let a = fx | |
| 959 | + .store | |
| 960 | + .create_issue(&fx.repo_id, &fx.alice.id, " First ", "body") | |
| 961 | + .await | |
| 962 | + .unwrap(); | |
| 963 | + let b = fx | |
| 964 | + .store | |
| 965 | + .create_issue(&fx.repo_id, &fx.alice.id, "Second", "") | |
| 966 | + .await | |
| 967 | + .unwrap(); | |
| 968 | + assert_eq!((a.number, b.number), (1, 2)); | |
| 969 | + assert_eq!(a.title, "First"); | |
| 970 | + assert_eq!(a.state, IssueState::Open); | |
| 971 | + assert_eq!(a.comment_count, 0); | |
| 972 | + let found = fx | |
| 973 | + .store | |
| 974 | + .issue_by_number(&fx.repo_id, 2) | |
| 975 | + .await | |
| 976 | + .unwrap() | |
| 977 | + .unwrap(); | |
| 978 | + assert_eq!(found.id, b.id); | |
| 979 | + assert!( | |
| 980 | + fx.store | |
| 981 | + .issue_by_number(&fx.repo_id, 3) | |
| 982 | + .await | |
| 983 | + .unwrap() | |
| 984 | + .is_none() | |
| 985 | + ); | |
| 986 | + } | |
| 987 | + | |
| 988 | + #[tokio::test] | |
| 989 | + async fn create_issue_validates_and_rejects_missing_repo() { | |
| 990 | + let fx = fixture().await; | |
| 991 | + assert!(matches!( | |
| 992 | + fx.store | |
| 993 | + .create_issue(&fx.repo_id, &fx.alice.id, " ", "") | |
| 994 | + .await, | |
| 995 | + Err(Error::Invalid(_)) | |
| 996 | + )); | |
| 997 | + assert!(matches!( | |
| 998 | + fx.store | |
| 999 | + .create_issue(&fx.repo_id, &fx.alice.id, "t", &"x".repeat(MAX_BODY + 1)) | |
| 1000 | + .await, | |
| 1001 | + Err(Error::Invalid(_)) | |
| 1002 | + )); | |
| 1003 | + assert!(matches!( | |
| 1004 | + fx.store | |
| 1005 | + .create_issue("missing", &fx.alice.id, "t", "") | |
| 1006 | + .await, | |
| 1007 | + Err(Error::NotFound) | |
| 1008 | + )); | |
| 1009 | + // A failed create does not consume a number. | |
| 1010 | + let ok = fx | |
| 1011 | + .store | |
| 1012 | + .create_issue(&fx.repo_id, &fx.alice.id, "t", "") | |
| 1013 | + .await | |
| 1014 | + .unwrap(); | |
| 1015 | + assert_eq!(ok.number, 1); | |
| 1016 | + } | |
| 1017 | + | |
| 1018 | + #[tokio::test] | |
| 1019 | + async fn concurrent_creates_get_unique_numbers_one_to_twenty() { | |
| 1020 | + let fx = fixture().await; | |
| 1021 | + let mut handles = Vec::new(); | |
| 1022 | + for i in 0..20 { | |
| 1023 | + let store = fx.store.clone(); | |
| 1024 | + let repo_id = fx.repo_id.clone(); | |
| 1025 | + let author = fx.alice.id.clone(); | |
| 1026 | + handles.push(tokio::spawn(async move { | |
| 1027 | + store | |
| 1028 | + .create_issue(&repo_id, &author, &format!("issue {i}"), "") | |
| 1029 | + .await | |
| 1030 | + })); | |
| 1031 | + } | |
| 1032 | + let mut numbers = Vec::new(); | |
| 1033 | + for h in handles { | |
| 1034 | + numbers.push(h.await.unwrap().unwrap().number); | |
| 1035 | + } | |
| 1036 | + numbers.sort_unstable(); | |
| 1037 | + assert_eq!(numbers, (1..=20).collect::<Vec<i64>>()); | |
| 1038 | + let (open, closed) = fx.store.issue_counts(&fx.repo_id).await.unwrap(); | |
| 1039 | + assert_eq!((open, closed), (20, 0)); | |
| 1040 | + } | |
| 1041 | + | |
| 1042 | + #[tokio::test] | |
| 1043 | + async fn list_filters_state_author_and_query() { | |
| 1044 | + let fx = fixture().await; | |
| 1045 | + let a = fx | |
| 1046 | + .store | |
| 1047 | + .create_issue(&fx.repo_id, &fx.alice.id, "Crash on start", "") | |
| 1048 | + .await | |
| 1049 | + .unwrap(); | |
| 1050 | + fx.store | |
| 1051 | + .create_issue(&fx.repo_id, &fx.bob.id, "Typo in docs", "") | |
| 1052 | + .await | |
| 1053 | + .unwrap(); | |
| 1054 | + fx.store | |
| 1055 | + .set_issue_state( | |
| 1056 | + &a.id, | |
| 1057 | + Some(&fx.alice.id), | |
| 1058 | + IssueState::Closed, | |
| 1059 | + Some(StateReason::Completed), | |
| 1060 | + Json::Null, | |
| 1061 | + ) | |
| 1062 | + .await | |
| 1063 | + .unwrap(); | |
| 1064 | + | |
| 1065 | + // Default filter (open only). | |
| 1066 | + let open = IssueFilter::default(); | |
| 1067 | + assert_eq!( | |
| 1068 | + titles(&fx.store, &fx.repo_id, &open).await, | |
| 1069 | + vec!["Typo in docs"] | |
| 1070 | + ); | |
| 1071 | + let (items, total) = fx.store.list_issues(&fx.repo_id, &all()).await.unwrap(); | |
| 1072 | + assert_eq!((items.len(), total), (2, 2)); | |
| 1073 | + | |
| 1074 | + let by_bob = IssueFilter { | |
| 1075 | + author: Some("BOB".into()), | |
| 1076 | + ..all() | |
| 1077 | + }; | |
| 1078 | + assert_eq!( | |
| 1079 | + titles(&fx.store, &fx.repo_id, &by_bob).await, | |
| 1080 | + vec!["Typo in docs"] | |
| 1081 | + ); | |
| 1082 | + | |
| 1083 | + let closed = IssueFilter { | |
| 1084 | + state: Some(IssueState::Closed), | |
| 1085 | + ..all() | |
| 1086 | + }; | |
| 1087 | + assert_eq!( | |
| 1088 | + titles(&fx.store, &fx.repo_id, &closed).await, | |
| 1089 | + vec!["Crash on start"] | |
| 1090 | + ); | |
| 1091 | + | |
| 1092 | + let q = IssueFilter { | |
| 1093 | + query: Some("CRASH".into()), | |
| 1094 | + ..all() | |
| 1095 | + }; | |
| 1096 | + assert_eq!( | |
| 1097 | + titles(&fx.store, &fx.repo_id, &q).await, | |
| 1098 | + vec!["Crash on start"] | |
| 1099 | + ); | |
| 1100 | + } | |
| 1101 | + | |
| 1102 | + #[tokio::test] | |
| 1103 | + async fn query_treats_wildcards_literally() { | |
| 1104 | + let fx = fixture().await; | |
| 1105 | + for t in [ | |
| 1106 | + "100% done", | |
| 1107 | + "100 done", | |
| 1108 | + "a_b", | |
| 1109 | + "axb", | |
| 1110 | + "back\\slash", | |
| 1111 | + "backXslash", | |
| 1112 | + ] { | |
| 1113 | + fx.store | |
| 1114 | + .create_issue(&fx.repo_id, &fx.alice.id, t, "") | |
| 1115 | + .await | |
| 1116 | + .unwrap(); | |
| 1117 | + } | |
| 1118 | + let find = |q: &str| IssueFilter { | |
| 1119 | + query: Some(q.into()), | |
| 1120 | + ..all() | |
| 1121 | + }; | |
| 1122 | + assert_eq!( | |
| 1123 | + titles(&fx.store, &fx.repo_id, &find("100%")).await, | |
| 1124 | + vec!["100% done"] | |
| 1125 | + ); | |
| 1126 | + assert_eq!( | |
| 1127 | + titles(&fx.store, &fx.repo_id, &find("a_b")).await, | |
| 1128 | + vec!["a_b"] | |
| 1129 | + ); | |
| 1130 | + assert_eq!( | |
| 1131 | + titles(&fx.store, &fx.repo_id, &find("\\")).await, | |
| 1132 | + vec!["back\\slash"] | |
| 1133 | + ); | |
| 1134 | + assert_eq!(titles(&fx.store, &fx.repo_id, &find("%")).await.len(), 1); | |
| 1135 | + assert_eq!(titles(&fx.store, &fx.repo_id, &find(" ")).await.len(), 6); | |
| 1136 | + } | |
| 1137 | + | |
| 1138 | + #[tokio::test] | |
| 1139 | + async fn list_paginates_and_clamps() { | |
| 1140 | + let fx = fixture().await; | |
| 1141 | + for i in 0..30 { | |
| 1142 | + fx.store | |
| 1143 | + .create_issue(&fx.repo_id, &fx.alice.id, &format!("issue {i}"), "") | |
| 1144 | + .await | |
| 1145 | + .unwrap(); | |
| 1146 | + } | |
| 1147 | + let page = |page: u32, per_page: u32| IssueFilter { | |
| 1148 | + page, | |
| 1149 | + per_page, | |
| 1150 | + ..all() | |
| 1151 | + }; | |
| 1152 | + let (first, total) = fx | |
| 1153 | + .store | |
| 1154 | + .list_issues(&fx.repo_id, &page(1, 25)) | |
| 1155 | + .await | |
| 1156 | + .unwrap(); | |
| 1157 | + assert_eq!((first.len(), total), (25, 30)); | |
| 1158 | + assert_eq!(first[0].issue.number, 30, "newest first"); | |
| 1159 | + let (second, _) = fx | |
| 1160 | + .store | |
| 1161 | + .list_issues(&fx.repo_id, &page(2, 25)) | |
| 1162 | + .await | |
| 1163 | + .unwrap(); | |
| 1164 | + assert_eq!(second.len(), 5); | |
| 1165 | + assert_eq!(second[4].issue.number, 1); | |
| 1166 | + // per_page clamps to 1..=100, page to 1..=1000. | |
| 1167 | + let (one, _) = fx | |
| 1168 | + .store | |
| 1169 | + .list_issues(&fx.repo_id, &page(0, 0)) | |
| 1170 | + .await | |
| 1171 | + .unwrap(); | |
| 1172 | + assert_eq!(one.len(), 1); | |
| 1173 | + assert_eq!(one[0].issue.number, 30); | |
| 1174 | + let (big, _) = fx | |
| 1175 | + .store | |
| 1176 | + .list_issues(&fx.repo_id, &page(5000, 1000)) | |
| 1177 | + .await | |
| 1178 | + .unwrap(); | |
| 1179 | + assert!(big.is_empty()); | |
| 1180 | + } | |
| 1181 | + | |
| 1182 | + #[tokio::test] | |
| 1183 | + async fn list_sorts_by_number_and_update_time() { | |
| 1184 | + let fx = fixture().await; | |
| 1185 | + let first = fx | |
| 1186 | + .store | |
| 1187 | + .create_issue(&fx.repo_id, &fx.alice.id, "one", "") | |
| 1188 | + .await | |
| 1189 | + .unwrap(); | |
| 1190 | + fx.store | |
| 1191 | + .create_issue(&fx.repo_id, &fx.alice.id, "two", "") | |
| 1192 | + .await | |
| 1193 | + .unwrap(); | |
| 1194 | + let oldest = IssueFilter { | |
| 1195 | + sort: IssueSort::Oldest, | |
| 1196 | + ..all() | |
| 1197 | + }; | |
| 1198 | + assert_eq!( | |
| 1199 | + titles(&fx.store, &fx.repo_id, &oldest).await, | |
| 1200 | + vec!["one", "two"] | |
| 1201 | + ); | |
| 1202 | + fx.store | |
| 1203 | + .add_comment(&first.id, &fx.bob.id, "bump") | |
| 1204 | + .await | |
| 1205 | + .unwrap(); | |
| 1206 | + let recent = IssueFilter { | |
| 1207 | + sort: IssueSort::RecentlyUpdated, | |
| 1208 | + ..all() | |
| 1209 | + }; | |
| 1210 | + assert_eq!( | |
| 1211 | + titles(&fx.store, &fx.repo_id, &recent).await, | |
| 1212 | + vec!["one", "two"] | |
| 1213 | + ); | |
| 1214 | + } | |
| 1215 | + | |
| 1216 | + #[tokio::test] | |
| 1217 | + async fn list_attaches_labels_and_assignees_in_batches() { | |
| 1218 | + let fx = fixture().await; | |
| 1219 | + let issue = fx | |
| 1220 | + .store | |
| 1221 | + .create_issue(&fx.repo_id, &fx.alice.id, "labelled", "") | |
| 1222 | + .await | |
| 1223 | + .unwrap(); | |
| 1224 | + let other = fx | |
| 1225 | + .store | |
| 1226 | + .create_issue(&fx.repo_id, &fx.alice.id, "plain", "") | |
| 1227 | + .await | |
| 1228 | + .unwrap(); | |
| 1229 | + let bug = fx | |
| 1230 | + .store | |
| 1231 | + .create_label(&fx.repo_id, "Bug", "#FF0000", "") | |
| 1232 | + .await | |
| 1233 | + .unwrap(); | |
| 1234 | + fx.store | |
| 1235 | + .set_issue_labels(&issue.id, &fx.alice.id, std::slice::from_ref(&bug.id)) | |
| 1236 | + .await | |
| 1237 | + .unwrap(); | |
| 1238 | + fx.store | |
| 1239 | + .set_issue_assignees(&issue.id, &fx.alice.id, &["bob".to_string()]) | |
| 1240 | + .await | |
| 1241 | + .unwrap(); | |
| 1242 | + | |
| 1243 | + let (items, _) = fx.store.list_issues(&fx.repo_id, &all()).await.unwrap(); | |
| 1244 | + let labelled = items.iter().find(|i| i.issue.id == issue.id).unwrap(); | |
| 1245 | + assert_eq!(labelled.labels.len(), 1); | |
| 1246 | + assert_eq!(labelled.labels[0].name, "Bug"); | |
| 1247 | + assert_eq!(labelled.assignees, vec!["Bob".to_string()]); | |
| 1248 | + assert_eq!(labelled.author, "Alice"); | |
| 1249 | + let plain = items.iter().find(|i| i.issue.id == other.id).unwrap(); | |
| 1250 | + assert!(plain.labels.is_empty() && plain.assignees.is_empty()); | |
| 1251 | + | |
| 1252 | + // Label and assignee filters, by name and username. | |
| 1253 | + let by_label = IssueFilter { | |
| 1254 | + label: Some("bug".into()), | |
| 1255 | + ..all() | |
| 1256 | + }; | |
| 1257 | + assert_eq!( | |
| 1258 | + titles(&fx.store, &fx.repo_id, &by_label).await, | |
| 1259 | + vec!["labelled"] | |
| 1260 | + ); | |
| 1261 | + let by_assignee = IssueFilter { | |
| 1262 | + assignee: Some("BOB".into()), | |
| 1263 | + ..all() | |
| 1264 | + }; | |
| 1265 | + assert_eq!( | |
| 1266 | + titles(&fx.store, &fx.repo_id, &by_assignee).await, | |
| 1267 | + vec!["labelled"] | |
| 1268 | + ); | |
| 1269 | + } | |
| 1270 | + | |
| 1271 | + #[tokio::test] | |
| 1272 | + async fn issue_counts_split_open_and_closed() { | |
| 1273 | + let fx = fixture().await; | |
| 1274 | + let a = fx | |
| 1275 | + .store | |
| 1276 | + .create_issue(&fx.repo_id, &fx.alice.id, "a", "") | |
| 1277 | + .await | |
| 1278 | + .unwrap(); | |
| 1279 | + fx.store | |
| 1280 | + .create_issue(&fx.repo_id, &fx.alice.id, "b", "") | |
| 1281 | + .await | |
| 1282 | + .unwrap(); | |
| 1283 | + fx.store | |
| 1284 | + .set_issue_state(&a.id, None, IssueState::Closed, None, Json::Null) | |
| 1285 | + .await | |
| 1286 | + .unwrap(); | |
| 1287 | + assert_eq!(fx.store.issue_counts(&fx.repo_id).await.unwrap(), (1, 1)); | |
| 1288 | + } | |
| 1289 | + | |
| 1290 | + #[tokio::test] | |
| 1291 | + async fn edit_issue_saves_history_and_records_rename() { | |
| 1292 | + let fx = fixture().await; | |
| 1293 | + let issue = fx | |
| 1294 | + .store | |
| 1295 | + .create_issue(&fx.repo_id, &fx.alice.id, "old", "old body") | |
| 1296 | + .await | |
| 1297 | + .unwrap(); | |
| 1298 | + fx.store | |
| 1299 | + .edit_issue(&issue.id, &fx.bob.id, "new", "new body") | |
| 1300 | + .await | |
| 1301 | + .unwrap(); | |
| 1302 | + let edited = fx | |
| 1303 | + .store | |
| 1304 | + .issue_by_number(&fx.repo_id, 1) | |
| 1305 | + .await | |
| 1306 | + .unwrap() | |
| 1307 | + .unwrap(); | |
| 1308 | + assert_eq!( | |
| 1309 | + (edited.title.as_str(), edited.body.as_str()), | |
| 1310 | + ("new", "new body") | |
| 1311 | + ); | |
| 1312 | + assert_ne!(edited.updated_at, issue.created_at); | |
| 1313 | + | |
| 1314 | + let conn = fx.store.inner.conn.lock().await; | |
| 1315 | + let mut rows = conn | |
| 1316 | + .query( | |
| 1317 | + "SELECT old_title, old_body, editor_id FROM edit_history WHERE target_kind = 'issue' AND target_id = ?1", | |
| 1318 | + (issue.id.clone(),), | |
| 1319 | + ) | |
| 1320 | + .await | |
| 1321 | + .unwrap(); | |
| 1322 | + let row = rows.next().await.unwrap().expect("history row"); | |
| 1323 | + assert_eq!(col_text(&row, 0).unwrap(), "old"); | |
| 1324 | + assert_eq!(col_text(&row, 1).unwrap(), "old body"); | |
| 1325 | + assert_eq!(col_text(&row, 2).unwrap(), fx.bob.id); | |
| 1326 | + drop(rows); | |
| 1327 | + drop(conn); | |
| 1328 | + | |
| 1329 | + let events = fx.store.list_events(&issue.id).await.unwrap(); | |
| 1330 | + assert_eq!(events.len(), 1); | |
| 1331 | + assert_eq!(events[0].kind, "renamed"); | |
| 1332 | + assert_eq!( | |
| 1333 | + events[0].data, | |
| 1334 | + serde_json::json!({"from": "old", "to": "new"}) | |
| 1335 | + ); | |
| 1336 | + assert_eq!(events[0].actor_id.as_deref(), Some(fx.bob.id.as_str())); | |
| 1337 | + | |
| 1338 | + // Body-only edit: history yes, no rename event. | |
| 1339 | + fx.store | |
| 1340 | + .edit_issue(&issue.id, &fx.bob.id, "new", "newer") | |
| 1341 | + .await | |
| 1342 | + .unwrap(); | |
| 1343 | + assert_eq!(fx.store.list_events(&issue.id).await.unwrap().len(), 1); | |
| 1344 | + assert!(matches!( | |
| 1345 | + fx.store.edit_issue("missing", &fx.bob.id, "t", "").await, | |
| 1346 | + Err(Error::NotFound) | |
| 1347 | + )); | |
| 1348 | + } | |
| 1349 | + | |
| 1350 | + #[tokio::test] | |
| 1351 | + async fn set_issue_locked_toggles_reason_and_records_events() { | |
| 1352 | + let fx = fixture().await; | |
| 1353 | + let issue = fx | |
| 1354 | + .store | |
| 1355 | + .create_issue(&fx.repo_id, &fx.alice.id, "t", "") | |
| 1356 | + .await | |
| 1357 | + .unwrap(); | |
| 1358 | + assert!(!issue.locked); | |
| 1359 | + assert_eq!(issue.lock_reason, None); | |
| 1360 | + | |
| 1361 | + assert!( | |
| 1362 | + !fx.store | |
| 1363 | + .set_issue_locked(&issue.id, None, false, None) | |
| 1364 | + .await | |
| 1365 | + .unwrap() | |
| 1366 | + ); | |
| 1367 | + assert!( | |
| 1368 | + fx.store | |
| 1369 | + .set_issue_locked(&issue.id, Some(&fx.alice.id), true, Some("spam")) | |
| 1370 | + .await | |
| 1371 | + .unwrap() | |
| 1372 | + ); | |
| 1373 | + let locked = fx | |
| 1374 | + .store | |
| 1375 | + .issue_by_number(&fx.repo_id, 1) | |
| 1376 | + .await | |
| 1377 | + .unwrap() | |
| 1378 | + .unwrap(); | |
| 1379 | + assert!(locked.locked); | |
| 1380 | + assert_eq!(locked.lock_reason.as_deref(), Some("spam")); | |
| 1381 | + | |
| 1382 | + assert!( | |
| 1383 | + !fx.store | |
| 1384 | + .set_issue_locked(&issue.id, None, true, Some("spam")) | |
| 1385 | + .await | |
| 1386 | + .unwrap() | |
| 1387 | + ); | |
| 1388 | + assert!( | |
| 1389 | + fx.store | |
| 1390 | + .set_issue_locked(&issue.id, Some(&fx.alice.id), false, Some("spam")) | |
| 1391 | + .await | |
| 1392 | + .unwrap() | |
| 1393 | + ); | |
| 1394 | + let open = fx | |
| 1395 | + .store | |
| 1396 | + .issue_by_number(&fx.repo_id, 1) | |
| 1397 | + .await | |
| 1398 | + .unwrap() | |
| 1399 | + .unwrap(); | |
| 1400 | + assert!(!open.locked); | |
| 1401 | + assert_eq!(open.lock_reason, None); | |
| 1402 | + | |
| 1403 | + let err = fx | |
| 1404 | + .store | |
| 1405 | + .set_issue_locked(&issue.id, None, true, Some("bogus")) | |
| 1406 | + .await; | |
| 1407 | + assert!(matches!(err, Err(Error::Invalid(_)))); | |
| 1408 | + | |
| 1409 | + let kinds: Vec<String> = fx | |
| 1410 | + .store | |
| 1411 | + .list_events(&issue.id) | |
| 1412 | + .await | |
| 1413 | + .unwrap() | |
| 1414 | + .into_iter() | |
| 1415 | + .map(|e| e.kind) | |
| 1416 | + .collect(); | |
| 1417 | + assert_eq!(kinds, vec!["locked", "unlocked"]); | |
| 1418 | + } | |
| 1419 | + | |
| 1420 | + #[tokio::test] | |
| 1421 | + async fn set_issue_state_closes_reopens_and_records_events() { | |
| 1422 | + let fx = fixture().await; | |
| 1423 | + let issue = fx | |
| 1424 | + .store | |
| 1425 | + .create_issue(&fx.repo_id, &fx.alice.id, "t", "") | |
| 1426 | + .await | |
| 1427 | + .unwrap(); | |
| 1428 | + | |
| 1429 | + assert!( | |
| 1430 | + !fx.store | |
| 1431 | + .set_issue_state(&issue.id, None, IssueState::Open, None, Json::Null) | |
| 1432 | + .await | |
| 1433 | + .unwrap() | |
| 1434 | + ); | |
| 1435 | + assert!( | |
| 1436 | + fx.store | |
| 1437 | + .set_issue_state( | |
| 1438 | + &issue.id, | |
| 1439 | + Some(&fx.alice.id), | |
| 1440 | + IssueState::Closed, | |
| 1441 | + Some(StateReason::NotPlanned), | |
| 1442 | + serde_json::json!({"x": 1}) | |
| 1443 | + ) | |
| 1444 | + .await | |
| 1445 | + .unwrap() | |
| 1446 | + ); | |
| 1447 | + assert!( | |
| 1448 | + !fx.store | |
| 1449 | + .set_issue_state( | |
| 1450 | + &issue.id, | |
| 1451 | + None, | |
| 1452 | + IssueState::Closed, | |
| 1453 | + Some(StateReason::Completed), | |
| 1454 | + Json::Null | |
| 1455 | + ) | |
| 1456 | + .await | |
| 1457 | + .unwrap() | |
| 1458 | + ); | |
| 1459 | + | |
| 1460 | + let closed = fx | |
| 1461 | + .store | |
| 1462 | + .issue_by_number(&fx.repo_id, 1) | |
| 1463 | + .await | |
| 1464 | + .unwrap() | |
| 1465 | + .unwrap(); | |
| 1466 | + assert_eq!(closed.state, IssueState::Closed); | |
| 1467 | + assert_eq!(closed.state_reason, Some(StateReason::NotPlanned)); | |
| 1468 | + assert!(closed.closed_at.is_some()); | |
| 1469 | + | |
| 1470 | + assert!( | |
| 1471 | + fx.store | |
| 1472 | + .set_issue_state(&issue.id, None, IssueState::Open, None, Json::Null) | |
| 1473 | + .await | |
| 1474 | + .unwrap() | |
| 1475 | + ); | |
| 1476 | + let reopened = fx | |
| 1477 | + .store | |
| 1478 | + .issue_by_number(&fx.repo_id, 1) | |
| 1479 | + .await | |
| 1480 | + .unwrap() | |
| 1481 | + .unwrap(); | |
| 1482 | + assert_eq!(reopened.state, IssueState::Open); | |
| 1483 | + assert_eq!(reopened.state_reason, None); | |
| 1484 | + assert_eq!(reopened.closed_at, None); | |
| 1485 | + | |
| 1486 | + let events = fx.store.list_events(&issue.id).await.unwrap(); | |
| 1487 | + let kinds: Vec<&str> = events.iter().map(|e| e.kind.as_str()).collect(); | |
| 1488 | + assert_eq!(kinds, vec!["closed", "reopened"]); | |
| 1489 | + assert_eq!( | |
| 1490 | + events[0].data, | |
| 1491 | + serde_json::json!({"x": 1, "reason": "not_planned"}) | |
| 1492 | + ); | |
| 1493 | + assert_eq!(events[1].data, serde_json::json!({})); | |
| 1494 | + assert!(events[1].actor_id.is_none()); | |
| 1495 | + | |
| 1496 | + assert!(matches!( | |
| 1497 | + fx.store | |
| 1498 | + .set_issue_state("missing", None, IssueState::Closed, None, Json::Null) | |
| 1499 | + .await, | |
| 1500 | + Err(Error::NotFound) | |
| 1501 | + )); | |
| 1502 | + assert!(matches!( | |
| 1503 | + fx.store | |
| 1504 | + .set_issue_state(&issue.id, None, IssueState::Closed, None, Json::from(5)) | |
| 1505 | + .await, | |
| 1506 | + Err(Error::Invalid(_)) | |
| 1507 | + )); | |
| 1508 | + } | |
| 1509 | + | |
| 1510 | + #[tokio::test] | |
| 1511 | + async fn comments_maintain_count_and_timestamps() { | |
| 1512 | + let fx = fixture().await; | |
| 1513 | + let issue = fx | |
| 1514 | + .store | |
| 1515 | + .create_issue(&fx.repo_id, &fx.alice.id, "t", "") | |
| 1516 | + .await | |
| 1517 | + .unwrap(); | |
| 1518 | + let c1 = fx | |
| 1519 | + .store | |
| 1520 | + .add_comment(&issue.id, &fx.bob.id, "first") | |
| 1521 | + .await | |
| 1522 | + .unwrap(); | |
| 1523 | + let c2 = fx | |
| 1524 | + .store | |
| 1525 | + .add_comment(&issue.id, &fx.alice.id, "second") | |
| 1526 | + .await | |
| 1527 | + .unwrap(); | |
| 1528 | + assert!(!c1.edited); | |
| 1529 | + let after_add = fx | |
| 1530 | + .store | |
| 1531 | + .issue_by_number(&fx.repo_id, 1) | |
| 1532 | + .await | |
| 1533 | + .unwrap() | |
| 1534 | + .unwrap(); | |
| 1535 | + assert_eq!(after_add.comment_count, 2); | |
| 1536 | + assert_ne!(after_add.updated_at, issue.updated_at); | |
| 1537 | + | |
| 1538 | + assert!(matches!( | |
| 1539 | + fx.store.add_comment(&issue.id, &fx.bob.id, " ").await, | |
| 1540 | + Err(Error::Invalid(_)) | |
| 1541 | + )); | |
| 1542 | + assert!(matches!( | |
| 1543 | + fx.store.add_comment("missing", &fx.bob.id, "x").await, | |
| 1544 | + Err(Error::NotFound) | |
| 1545 | + )); | |
| 1546 | + | |
| 1547 | + let found = fx | |
| 1548 | + .store | |
| 1549 | + .comment_by_id(&issue.id, &c1.id) | |
| 1550 | + .await | |
| 1551 | + .unwrap() | |
| 1552 | + .unwrap(); | |
| 1553 | + assert_eq!(found.body, "first"); | |
| 1554 | + assert!( | |
| 1555 | + fx.store | |
| 1556 | + .comment_by_id("other", &c1.id) | |
| 1557 | + .await | |
| 1558 | + .unwrap() | |
| 1559 | + .is_none() | |
| 1560 | + ); | |
| 1561 | + | |
| 1562 | + fx.store | |
| 1563 | + .edit_comment(&c1.id, &fx.bob.id, "edited") | |
| 1564 | + .await | |
| 1565 | + .unwrap(); | |
| 1566 | + let edited = fx | |
| 1567 | + .store | |
| 1568 | + .comment_by_id(&issue.id, &c1.id) | |
| 1569 | + .await | |
| 1570 | + .unwrap() | |
| 1571 | + .unwrap(); | |
| 1572 | + assert_eq!(edited.body, "edited"); | |
| 1573 | + assert!(edited.edited); | |
| 1574 | + assert!(matches!( | |
| 1575 | + fx.store.edit_comment("missing", &fx.bob.id, "x").await, | |
| 1576 | + Err(Error::NotFound) | |
| 1577 | + )); | |
| 1578 | + assert!(matches!( | |
| 1579 | + fx.store.edit_comment(&c1.id, &fx.bob.id, "").await, | |
| 1580 | + Err(Error::Invalid(_)) | |
| 1581 | + )); | |
| 1582 | + | |
| 1583 | + let comments = fx.store.list_comments(&issue.id).await.unwrap(); | |
| 1584 | + let bodies: Vec<&str> = comments.iter().map(|c| c.body.as_str()).collect(); | |
| 1585 | + assert_eq!(bodies, vec!["edited", "second"]); | |
| 1586 | + | |
| 1587 | + fx.store.delete_comment(&c2.id).await.unwrap(); | |
| 1588 | + let after_delete = fx | |
| 1589 | + .store | |
| 1590 | + .issue_by_number(&fx.repo_id, 1) | |
| 1591 | + .await | |
| 1592 | + .unwrap() | |
| 1593 | + .unwrap(); | |
| 1594 | + assert_eq!(after_delete.comment_count, 1); | |
| 1595 | + assert!( | |
| 1596 | + fx.store | |
| 1597 | + .comment_by_id(&issue.id, &c2.id) | |
| 1598 | + .await | |
| 1599 | + .unwrap() | |
| 1600 | + .is_none() | |
| 1601 | + ); | |
| 1602 | + assert!(matches!( | |
| 1603 | + fx.store.delete_comment(&c2.id).await, | |
| 1604 | + Err(Error::NotFound) | |
| 1605 | + )); | |
| 1606 | + } | |
| 1607 | + | |
| 1608 | + #[tokio::test] | |
| 1609 | + async fn add_event_validates_kind_and_issue() { | |
| 1610 | + let fx = fixture().await; | |
| 1611 | + let issue = fx | |
| 1612 | + .store | |
| 1613 | + .create_issue(&fx.repo_id, &fx.alice.id, "t", "") | |
| 1614 | + .await | |
| 1615 | + .unwrap(); | |
| 1616 | + fx.store | |
| 1617 | + .add_event( | |
| 1618 | + &issue.id, | |
| 1619 | + None, | |
| 1620 | + "referenced", | |
| 1621 | + serde_json::json!({"commit": "abc"}), | |
| 1622 | + ) | |
| 1623 | + .await | |
| 1624 | + .unwrap(); | |
| 1625 | + fx.store | |
| 1626 | + .add_event(&issue.id, Some(&fx.bob.id), "referenced", Json::Null) | |
| 1627 | + .await | |
| 1628 | + .unwrap(); | |
| 1629 | + let events = fx.store.list_events(&issue.id).await.unwrap(); | |
| 1630 | + assert_eq!(events.len(), 2); | |
| 1631 | + assert_eq!(events[0].data, serde_json::json!({"commit": "abc"})); | |
| 1632 | + assert_eq!(events[1].data, serde_json::json!({})); | |
| 1633 | + assert!(matches!( | |
| 1634 | + fx.store | |
| 1635 | + .add_event(&issue.id, None, "exploded", Json::Null) | |
| 1636 | + .await, | |
| 1637 | + Err(Error::Invalid(_)) | |
| 1638 | + )); | |
| 1639 | + assert!(matches!( | |
| 1640 | + fx.store | |
| 1641 | + .add_event(&issue.id, None, "referenced", Json::from(true)) | |
| 1642 | + .await, | |
| 1643 | + Err(Error::Invalid(_)) | |
| 1644 | + )); | |
| 1645 | + assert!(matches!( | |
| 1646 | + fx.store | |
| 1647 | + .add_event("missing", None, "referenced", Json::Null) | |
| 1648 | + .await, | |
| 1649 | + Err(Error::NotFound) | |
| 1650 | + )); | |
| 1651 | + } | |
| 1652 | + | |
| 1653 | + #[tokio::test] | |
| 1654 | + async fn usernames_by_ids_is_batched_and_skips_unknown() { | |
| 1655 | + let fx = fixture().await; | |
| 1656 | + let ids = vec![fx.alice.id.clone(), fx.bob.id.clone(), "nobody".to_string()]; | |
| 1657 | + let map = fx.store.usernames_by_ids(&ids).await.unwrap(); | |
| 1658 | + assert_eq!(map.len(), 2); | |
| 1659 | + assert_eq!(map[&fx.alice.id].0, "Alice"); | |
| 1660 | + assert_eq!(map[&fx.bob.id].1, None); | |
| 1661 | + assert!(fx.store.usernames_by_ids(&[]).await.unwrap().is_empty()); | |
| 1662 | + } | |
| 1663 | + | |
| 1664 | + #[tokio::test] | |
| 1665 | + async fn delete_repo_leaves_no_m3_rows() { | |
| 1666 | + let fx = fixture().await; | |
| 1667 | + let issue = fx | |
| 1668 | + .store | |
| 1669 | + .create_issue(&fx.repo_id, &fx.alice.id, "t", "") | |
| 1670 | + .await | |
| 1671 | + .unwrap(); | |
| 1672 | + let comment = fx | |
| 1673 | + .store | |
| 1674 | + .add_comment(&issue.id, &fx.bob.id, "c") | |
| 1675 | + .await | |
| 1676 | + .unwrap(); | |
| 1677 | + fx.store | |
| 1678 | + .edit_comment(&comment.id, &fx.bob.id, "c2") | |
| 1679 | + .await | |
| 1680 | + .unwrap(); | |
| 1681 | + fx.store | |
| 1682 | + .edit_issue(&issue.id, &fx.alice.id, "t2", "") | |
| 1683 | + .await | |
| 1684 | + .unwrap(); | |
| 1685 | + let label = fx | |
| 1686 | + .store | |
| 1687 | + .create_label(&fx.repo_id, "bug", "#000000", "") | |
| 1688 | + .await | |
| 1689 | + .unwrap(); | |
| 1690 | + fx.store | |
| 1691 | + .set_issue_labels(&issue.id, &fx.alice.id, std::slice::from_ref(&label.id)) | |
| 1692 | + .await | |
| 1693 | + .unwrap(); | |
| 1694 | + fx.store | |
| 1695 | + .set_issue_assignees(&issue.id, &fx.alice.id, &["bob".into()]) | |
| 1696 | + .await | |
| 1697 | + .unwrap(); | |
| 1698 | + let ms = fx | |
| 1699 | + .store | |
| 1700 | + .create_milestone(&fx.repo_id, "v1", "", Some("2030-01-01")) | |
| 1701 | + .await | |
| 1702 | + .unwrap(); | |
| 1703 | + fx.store | |
| 1704 | + .set_issue_milestone(&issue.id, &fx.alice.id, Some(&ms.id)) | |
| 1705 | + .await | |
| 1706 | + .unwrap(); | |
| 1707 | + | |
| 1708 | + fx.store.delete_repo(&fx.repo_id).await.unwrap(); | |
| 1709 | + | |
| 1710 | + let conn = fx.store.inner.conn.lock().await; | |
| 1711 | + for table in [ | |
| 1712 | + "issues", | |
| 1713 | + "comments", | |
| 1714 | + "edit_history", | |
| 1715 | + "issue_events", | |
| 1716 | + "labels", | |
| 1717 | + "issue_labels", | |
| 1718 | + "issue_assignees", | |
| 1719 | + "milestones", | |
| 1720 | + "reactions", | |
| 1721 | + ] { | |
| 1722 | + let mut rows = conn | |
| 1723 | + .query(format!("SELECT COUNT(*) FROM {table}"), ()) | |
| 1724 | + .await | |
| 1725 | + .unwrap(); | |
| 1726 | + let row = rows.next().await.unwrap().unwrap(); | |
| 1727 | + assert_eq!(col_int(&row, 0).unwrap(), 0, "{table} still has rows"); | |
| 1728 | + } | |
| 1729 | + } | |
| 1730 | +} |
crates/forge-core/src/store/labels.rsDiff too large; view file
| @@ -0,0 +1,1338 @@ | ||
| 1 | +//! Labels, issue label/assignee/milestone links, and milestones. Every function is scoped by | |
| 2 | +//! repo: an id that belongs to another repo is `NotFound` or `Invalid`, never acted on. | |
| 3 | + | |
| 4 | +use std::collections::{BTreeMap, BTreeSet}; | |
| 5 | + | |
| 6 | +use jiff::civil::Date; | |
| 7 | +use serde_json::{Value, json}; | |
| 8 | +use turso::transaction::Transaction; | |
| 9 | +use turso::{Connection, Row}; | |
| 10 | + | |
| 11 | +use super::{ | |
| 12 | + Store, col_int, col_opt_text, col_text, db_err, fetch_all, fetch_opt, insert_err, new_id, | |
| 13 | +}; | |
| 14 | +use crate::authz::{Action, authorize}; | |
| 15 | +use crate::error::{Error, Result}; | |
| 16 | +use crate::issues::{IssueState, Label, Milestone, validate_body, validate_label, validate_title}; | |
| 17 | +use crate::model::User; | |
| 18 | +use crate::names::normalize_name; | |
| 19 | +use crate::time::now_stamp; | |
| 20 | + | |
| 21 | +/// Most assignees one issue can have. | |
| 22 | +pub const MAX_ASSIGNEES: usize = 10; | |
| 23 | + | |
| 24 | +const LABEL_TAKEN: &str = "a label with that name already exists"; | |
| 25 | +const MILESTONE_TAKEN: &str = "a milestone with that title already exists"; | |
| 26 | + | |
| 27 | +/// Labels a new repo starts with: (name, color, description). | |
| 28 | +pub const DEFAULT_LABELS: [(&str, &str, &str); 9] = [ | |
| 29 | + ("bug", "#d73a4a", "Something isn't working"), | |
| 30 | + ( | |
| 31 | + "documentation", | |
| 32 | + "#0075ca", | |
| 33 | + "Improvements or additions to documentation", | |
| 34 | + ), | |
| 35 | + ( | |
| 36 | + "duplicate", | |
| 37 | + "#cfd3d7", | |
| 38 | + "This issue or pull request already exists", | |
| 39 | + ), | |
| 40 | + ("enhancement", "#a2eeef", "New feature or request"), | |
| 41 | + ("good first issue", "#7057ff", "Good for newcomers"), | |
| 42 | + ("help wanted", "#008672", "Extra attention is needed"), | |
| 43 | + ("invalid", "#e4e669", "This doesn't seem right"), | |
| 44 | + ("question", "#d876e3", "Further information is requested"), | |
| 45 | + ("wontfix", "#ffffff", "This will not be worked on"), | |
| 46 | +]; | |
| 47 | + | |
| 48 | +const LABEL_COLS: &str = "id, repo_id, name, color, description"; | |
| 49 | +/// One grouped query: each milestone with its open and closed issue counts. Joining on | |
| 50 | +/// `repo_id` as well lets the lookup use the issues index. | |
| 51 | +const MILESTONE_SELECT: &str = "SELECT m.id, m.repo_id, m.title, m.description, m.due_on, m.state, \ | |
| 52 | + m.created_at, m.closed_at, \ | |
| 53 | + COUNT(CASE WHEN i.state = 'open' THEN 1 END), COUNT(CASE WHEN i.state = 'closed' THEN 1 END) \ | |
| 54 | + FROM milestones m LEFT JOIN issues i ON i.repo_id = m.repo_id AND i.milestone_id = m.id"; | |
| 55 | + | |
| 56 | +// labels | |
| 57 | + | |
| 58 | +impl Store { | |
| 59 | + /// The repo's labels, ordered by case-folded name. | |
| 60 | + pub async fn list_labels(&self, repo_id: &str) -> Result<Vec<Label>> { | |
| 61 | + let conn = self.read_conn().await; | |
| 62 | + fetch_all( | |
| 63 | + &conn, | |
| 64 | + &format!("SELECT {LABEL_COLS} FROM labels WHERE repo_id = ?1 ORDER BY name_folded"), | |
| 65 | + (repo_id.to_string(),), | |
| 66 | + map_label, | |
| 67 | + ) | |
| 68 | + .await | |
| 69 | + } | |
| 70 | + | |
| 71 | + /// Open issue count per label of `repo_id`, as `(label_id, count)`, in one grouped query. | |
| 72 | + /// Labels with no open issues are included with 0. | |
| 73 | + pub async fn label_open_counts(&self, repo_id: &str) -> Result<Vec<(String, i64)>> { | |
| 74 | + let conn = self.read_conn().await; | |
| 75 | + fetch_all( | |
| 76 | + &conn, | |
| 77 | + "SELECT l.id, COUNT(i.id) FROM labels l \ | |
| 78 | + LEFT JOIN issue_labels il ON il.label_id = l.id \ | |
| 79 | + LEFT JOIN issues i ON i.id = il.issue_id AND i.state = 'open' AND i.kind = 'issue' \ | |
| 80 | + WHERE l.repo_id = ?1 GROUP BY l.id", | |
| 81 | + (repo_id.to_string(),), | |
| 82 | + |row| Ok((col_text(row, 0)?, col_int(row, 1)?)), | |
| 83 | + ) | |
| 84 | + .await | |
| 85 | + } | |
| 86 | + | |
| 87 | + /// Creates a label. A name that folds to an existing one in the same repo is `Conflict`. | |
| 88 | + pub async fn create_label( | |
| 89 | + &self, | |
| 90 | + repo_id: &str, | |
| 91 | + name: &str, | |
| 92 | + color: &str, | |
| 93 | + description: &str, | |
| 94 | + ) -> Result<Label> { | |
| 95 | + let (name, color) = validate_label(name, color)?; | |
| 96 | + let folded = normalize_name(&name); | |
| 97 | + let label = Label { | |
| 98 | + id: new_id(), | |
| 99 | + repo_id: repo_id.to_string(), | |
| 100 | + name, | |
| 101 | + color, | |
| 102 | + description: description.to_string(), | |
| 103 | + }; | |
| 104 | + let conn = self.inner.conn.lock().await; | |
| 105 | + conn.execute( | |
| 106 | + "INSERT INTO labels (id, repo_id, name, name_folded, color, description) \ | |
| 107 | + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", | |
| 108 | + ( | |
| 109 | + label.id.clone(), | |
| 110 | + label.repo_id.clone(), | |
| 111 | + label.name.clone(), | |
| 112 | + folded, | |
| 113 | + label.color.clone(), | |
| 114 | + label.description.clone(), | |
| 115 | + ), | |
| 116 | + ) | |
| 117 | + .await | |
| 118 | + .map_err(|e| insert_err(e, LABEL_TAKEN))?; | |
| 119 | + Ok(label) | |
| 120 | + } | |
| 121 | + | |
| 122 | + /// Adds the default labels to `repo_id`, skipping any whose name the repo already has. | |
| 123 | + /// Returns how many were created. Running it again creates nothing. | |
| 124 | + pub async fn create_default_labels(&self, repo_id: &str) -> Result<usize> { | |
| 125 | + let mut created = 0; | |
| 126 | + for (name, color, description) in DEFAULT_LABELS { | |
| 127 | + match self.create_label(repo_id, name, color, description).await { | |
| 128 | + Ok(_) => created += 1, | |
| 129 | + Err(Error::Conflict(_)) => {} | |
| 130 | + Err(e) => return Err(e), | |
| 131 | + } | |
| 132 | + } | |
| 133 | + Ok(created) | |
| 134 | + } | |
| 135 | + | |
| 136 | + /// Updates a label in place. `NotFound` if the label is not in `repo_id`. | |
| 137 | + pub async fn update_label( | |
| 138 | + &self, | |
| 139 | + repo_id: &str, | |
| 140 | + label_id: &str, | |
| 141 | + name: &str, | |
| 142 | + color: &str, | |
| 143 | + description: &str, | |
| 144 | + ) -> Result<()> { | |
| 145 | + let (name, color) = validate_label(name, color)?; | |
| 146 | + let folded = normalize_name(&name); | |
| 147 | + let conn = self.inner.conn.lock().await; | |
| 148 | + let n = conn | |
| 149 | + .execute( | |
| 150 | + "UPDATE labels SET name = ?3, name_folded = ?4, color = ?5, description = ?6 \ | |
| 151 | + WHERE id = ?1 AND repo_id = ?2", | |
| 152 | + ( | |
| 153 | + label_id.to_string(), | |
| 154 | + repo_id.to_string(), | |
| 155 | + name, | |
| 156 | + folded, | |
| 157 | + color, | |
| 158 | + description.to_string(), | |
| 159 | + ), | |
| 160 | + ) | |
| 161 | + .await | |
| 162 | + .map_err(|e| insert_err(e, LABEL_TAKEN))?; | |
| 163 | + if n == 0 { | |
| 164 | + return Err(Error::NotFound); | |
| 165 | + } | |
| 166 | + Ok(()) | |
| 167 | + } | |
| 168 | + | |
| 169 | + /// Deletes a label and its issue links in one transaction. `NotFound` if the label is not | |
| 170 | + /// in `repo_id`; nothing is changed then. | |
| 171 | + pub async fn delete_label(&self, repo_id: &str, label_id: &str) -> Result<()> { | |
| 172 | + let conn = self.inner.conn.lock().await; | |
| 173 | + let tx = conn.unchecked_transaction().await.map_err(db_err)?; | |
| 174 | + let outcome: Result<()> = async { | |
| 175 | + let n = tx | |
| 176 | + .execute( | |
| 177 | + "DELETE FROM labels WHERE id = ?1 AND repo_id = ?2", | |
| 178 | + (label_id.to_string(), repo_id.to_string()), | |
| 179 | + ) | |
| 180 | + .await | |
| 181 | + .map_err(db_err)?; | |
| 182 | + if n == 0 { | |
| 183 | + return Err(Error::NotFound); | |
| 184 | + } | |
| 185 | + tx.execute( | |
| 186 | + "DELETE FROM issue_labels WHERE label_id = ?1", | |
| 187 | + (label_id.to_string(),), | |
| 188 | + ) | |
| 189 | + .await | |
| 190 | + .map_err(db_err)?; | |
| 191 | + Ok(()) | |
| 192 | + } | |
| 193 | + .await; | |
| 194 | + commit_or_rollback(tx, outcome).await | |
| 195 | + } | |
| 196 | + | |
| 197 | + /// The labels on one issue, ordered by case-folded name. | |
| 198 | + pub async fn issue_labels(&self, issue_id: &str) -> Result<Vec<Label>> { | |
| 199 | + let conn = self.read_conn().await; | |
| 200 | + fetch_all( | |
| 201 | + &conn, | |
| 202 | + "SELECT l.id, l.repo_id, l.name, l.color, l.description FROM issue_labels il \ | |
| 203 | + JOIN labels l ON l.id = il.label_id WHERE il.issue_id = ?1 ORDER BY l.name_folded", | |
| 204 | + (issue_id.to_string(),), | |
| 205 | + map_label, | |
| 206 | + ) | |
| 207 | + .await | |
| 208 | + } | |
| 209 | + | |
| 210 | + /// Replaces the issue's label set. Adds and removes happen in one transaction, with one | |
| 211 | + /// `labeled` or `unlabeled` event per change. Every id must be a label of the issue's repo; | |
| 212 | + /// otherwise `Invalid` and nothing changes. | |
| 213 | + pub async fn set_issue_labels( | |
| 214 | + &self, | |
| 215 | + issue_id: &str, | |
| 216 | + actor_id: &str, | |
| 217 | + label_ids: &[String], | |
| 218 | + ) -> Result<()> { | |
| 219 | + let requested: BTreeSet<&str> = label_ids.iter().map(String::as_str).collect(); | |
| 220 | + let conn = self.inner.conn.lock().await; | |
| 221 | + let tx = conn.unchecked_transaction().await.map_err(db_err)?; | |
| 222 | + let outcome: Result<()> = async { | |
| 223 | + let repo_id = issue_repo_id(&tx, issue_id).await?; | |
| 224 | + let repo_labels: BTreeMap<String, String> = fetch_all( | |
| 225 | + &tx, | |
| 226 | + "SELECT id, name FROM labels WHERE repo_id = ?1", | |
| 227 | + (repo_id,), | |
| 228 | + |row| Ok((col_text(row, 0)?, col_text(row, 1)?)), | |
| 229 | + ) | |
| 230 | + .await? | |
| 231 | + .into_iter() | |
| 232 | + .collect(); | |
| 233 | + if let Some(bad) = requested.iter().find(|id| !repo_labels.contains_key(**id)) { | |
| 234 | + return Err(Error::Invalid(format!( | |
| 235 | + "label {bad} is not in this repository" | |
| 236 | + ))); | |
| 237 | + } | |
| 238 | + | |
| 239 | + let current: BTreeMap<String, String> = fetch_all( | |
| 240 | + &tx, | |
| 241 | + "SELECT il.label_id, l.name FROM issue_labels il \ | |
| 242 | + JOIN labels l ON l.id = il.label_id WHERE il.issue_id = ?1", | |
| 243 | + (issue_id.to_string(),), | |
| 244 | + |row| Ok((col_text(row, 0)?, col_text(row, 1)?)), | |
| 245 | + ) | |
| 246 | + .await? | |
| 247 | + .into_iter() | |
| 248 | + .collect(); | |
| 249 | + | |
| 250 | + let mut removed: Vec<(&String, &String)> = current | |
| 251 | + .iter() | |
| 252 | + .filter(|(id, _)| !requested.contains(id.as_str())) | |
| 253 | + .collect(); | |
| 254 | + removed.sort_by(|a, b| a.1.cmp(b.1)); | |
| 255 | + for (id, name) in removed { | |
| 256 | + tx.execute( | |
| 257 | + "DELETE FROM issue_labels WHERE issue_id = ?1 AND label_id = ?2", | |
| 258 | + (issue_id.to_string(), id.clone()), | |
| 259 | + ) | |
| 260 | + .await | |
| 261 | + .map_err(db_err)?; | |
| 262 | + insert_event( | |
| 263 | + &tx, | |
| 264 | + issue_id, | |
| 265 | + Some(actor_id), | |
| 266 | + "unlabeled", | |
| 267 | + &json!({ "label": name }), | |
| 268 | + ) | |
| 269 | + .await?; | |
| 270 | + } | |
| 271 | + | |
| 272 | + let mut added: Vec<(&str, &String)> = requested | |
| 273 | + .iter() | |
| 274 | + .filter(|id| !current.contains_key(**id)) | |
| 275 | + .map(|id| (*id, &repo_labels[*id])) | |
| 276 | + .collect(); | |
| 277 | + added.sort_by(|a, b| a.1.cmp(b.1)); | |
| 278 | + for (id, name) in added { | |
| 279 | + tx.execute( | |
| 280 | + "INSERT INTO issue_labels (issue_id, label_id) VALUES (?1, ?2)", | |
| 281 | + (issue_id.to_string(), id.to_string()), | |
| 282 | + ) | |
| 283 | + .await | |
| 284 | + .map_err(db_err)?; | |
| 285 | + insert_event( | |
| 286 | + &tx, | |
| 287 | + issue_id, | |
| 288 | + Some(actor_id), | |
| 289 | + "labeled", | |
| 290 | + &json!({ "label": name }), | |
| 291 | + ) | |
| 292 | + .await?; | |
| 293 | + } | |
| 294 | + Ok(()) | |
| 295 | + } | |
| 296 | + .await; | |
| 297 | + commit_or_rollback(tx, outcome).await | |
| 298 | + } | |
| 299 | + | |
| 300 | + /// The issue's assignees as `(user id, display username)`, ordered by name. | |
| 301 | + pub async fn issue_assignees(&self, issue_id: &str) -> Result<Vec<(String, String)>> { | |
| 302 | + let conn = self.read_conn().await; | |
| 303 | + fetch_all( | |
| 304 | + &conn, | |
| 305 | + "SELECT u.id, u.username FROM issue_assignees a JOIN users u ON u.id = a.user_id \ | |
| 306 | + WHERE a.issue_id = ?1 ORDER BY u.username_folded", | |
| 307 | + (issue_id.to_string(),), | |
| 308 | + |row| Ok((col_text(row, 0)?, col_text(row, 1)?)), | |
| 309 | + ) | |
| 310 | + .await | |
| 311 | + } | |
| 312 | + | |
| 313 | + /// Replaces the issue's assignees. Usernames are trimmed and deduplicated case-insensitively | |
| 314 | + /// (the first spelling wins). At most `MAX_ASSIGNEES`. Each user must be able to Read the | |
| 315 | + /// repo, otherwise `Invalid`. Records `assigned` and `unassigned` events. | |
| 316 | + pub async fn set_issue_assignees( | |
| 317 | + &self, | |
| 318 | + issue_id: &str, | |
| 319 | + actor_id: &str, | |
| 320 | + usernames: &[String], | |
| 321 | + ) -> Result<()> { | |
| 322 | + let mut seen = BTreeSet::new(); | |
| 323 | + let mut names: Vec<&str> = Vec::new(); | |
| 324 | + for raw in usernames { | |
| 325 | + let name = raw.trim(); | |
| 326 | + if !name.is_empty() && seen.insert(normalize_name(name)) { | |
| 327 | + names.push(name); | |
| 328 | + } | |
| 329 | + } | |
| 330 | + if names.len() > MAX_ASSIGNEES { | |
| 331 | + return Err(Error::Invalid(format!( | |
| 332 | + "an issue can have at most {MAX_ASSIGNEES} assignees" | |
| 333 | + ))); | |
| 334 | + } | |
| 335 | + | |
| 336 | + // These helpers take the connection lock themselves, so they run before it is held. | |
| 337 | + let repo_id = { | |
| 338 | + let conn = self.inner.conn.lock().await; | |
| 339 | + issue_repo_id(&conn, issue_id).await? | |
| 340 | + }; | |
| 341 | + let repo = self.repo_by_id(&repo_id).await?.ok_or(Error::NotFound)?; | |
| 342 | + let mut assignees: Vec<User> = Vec::with_capacity(names.len()); | |
| 343 | + for name in names { | |
| 344 | + let user = self | |
| 345 | + .user_by_username(name) | |
| 346 | + .await? | |
| 347 | + .ok_or_else(|| Error::Invalid(format!("no such user: {name}")))?; | |
| 348 | + let role = self.collaborator_role(&repo_id, &user.id).await?; | |
| 349 | + let org_role = self | |
| 350 | + .org_repo_role(&repo.owner_id, &user.id, &repo_id) | |
| 351 | + .await?; | |
| 352 | + authorize(Some(&user), &repo, role, org_role, Action::Read).map_err(|_| { | |
| 353 | + Error::Invalid(format!("{} cannot read this repository", user.username)) | |
| 354 | + })?; | |
| 355 | + assignees.push(user); | |
| 356 | + } | |
| 357 | + | |
| 358 | + let conn = self.inner.conn.lock().await; | |
| 359 | + let tx = conn.unchecked_transaction().await.map_err(db_err)?; | |
| 360 | + let outcome: Result<()> = async { | |
| 361 | + issue_repo_id(&tx, issue_id).await?; | |
| 362 | + let current: Vec<(String, String)> = fetch_all( | |
| 363 | + &tx, | |
| 364 | + "SELECT a.user_id, u.username FROM issue_assignees a \ | |
| 365 | + JOIN users u ON u.id = a.user_id WHERE a.issue_id = ?1 ORDER BY u.username_folded", | |
| 366 | + (issue_id.to_string(),), | |
| 367 | + |row| Ok((col_text(row, 0)?, col_text(row, 1)?)), | |
| 368 | + ) | |
| 369 | + .await?; | |
| 370 | + let wanted: BTreeSet<&str> = assignees.iter().map(|u| u.id.as_str()).collect(); | |
| 371 | + let have: BTreeSet<&str> = current.iter().map(|(id, _)| id.as_str()).collect(); | |
| 372 | + | |
| 373 | + for (id, display) in ¤t { | |
| 374 | + if wanted.contains(id.as_str()) { | |
| 375 | + continue; | |
| 376 | + } | |
| 377 | + tx.execute( | |
| 378 | + "DELETE FROM issue_assignees WHERE issue_id = ?1 AND user_id = ?2", | |
| 379 | + (issue_id.to_string(), id.clone()), | |
| 380 | + ) | |
| 381 | + .await | |
| 382 | + .map_err(db_err)?; | |
| 383 | + insert_event( | |
| 384 | + &tx, | |
| 385 | + issue_id, | |
| 386 | + Some(actor_id), | |
| 387 | + "unassigned", | |
| 388 | + &json!({ "user": display }), | |
| 389 | + ) | |
| 390 | + .await?; | |
| 391 | + } | |
| 392 | + for user in &assignees { | |
| 393 | + if have.contains(user.id.as_str()) { | |
| 394 | + continue; | |
| 395 | + } | |
| 396 | + tx.execute( | |
| 397 | + "INSERT INTO issue_assignees (issue_id, user_id) VALUES (?1, ?2)", | |
| 398 | + (issue_id.to_string(), user.id.clone()), | |
| 399 | + ) | |
| 400 | + .await | |
| 401 | + .map_err(db_err)?; | |
| 402 | + insert_event( | |
| 403 | + &tx, | |
| 404 | + issue_id, | |
| 405 | + Some(actor_id), | |
| 406 | + "assigned", | |
| 407 | + &json!({ "user": user.username }), | |
| 408 | + ) | |
| 409 | + .await?; | |
| 410 | + } | |
| 411 | + Ok(()) | |
| 412 | + } | |
| 413 | + .await; | |
| 414 | + commit_or_rollback(tx, outcome).await | |
| 415 | + } | |
| 416 | + | |
| 417 | + // milestones | |
| 418 | + | |
| 419 | + /// The repo's milestones ordered by title. `state` filters when given. | |
| 420 | + pub async fn list_milestones( | |
| 421 | + &self, | |
| 422 | + repo_id: &str, | |
| 423 | + state: Option<IssueState>, | |
| 424 | + ) -> Result<Vec<Milestone>> { | |
| 425 | + let state = state.map(|s| s.as_str().to_string()); | |
| 426 | + let conn = self.read_conn().await; | |
| 427 | + fetch_all( | |
| 428 | + &conn, | |
| 429 | + &format!( | |
| 430 | + "{MILESTONE_SELECT} WHERE m.repo_id = ?1 AND (?2 IS NULL OR m.state = ?2) \ | |
| 431 | + GROUP BY m.id ORDER BY m.title_folded" | |
| 432 | + ), | |
| 433 | + (repo_id.to_string(), state), | |
| 434 | + map_milestone, | |
| 435 | + ) | |
| 436 | + .await | |
| 437 | + } | |
| 438 | + | |
| 439 | + /// One milestone with its issue counts, or `None` if it is not in `repo_id`. | |
| 440 | + pub async fn milestone_by_id(&self, repo_id: &str, id: &str) -> Result<Option<Milestone>> { | |
| 441 | + let conn = self.read_conn().await; | |
| 442 | + fetch_opt( | |
| 443 | + &conn, | |
| 444 | + &format!("{MILESTONE_SELECT} WHERE m.repo_id = ?1 AND m.id = ?2 GROUP BY m.id"), | |
| 445 | + (repo_id.to_string(), id.to_string()), | |
| 446 | + map_milestone, | |
| 447 | + ) | |
| 448 | + .await | |
| 449 | + } | |
| 450 | + | |
| 451 | + /// Creates an open milestone. `due_on` must be `YYYY-MM-DD` when given. | |
| 452 | + pub async fn create_milestone( | |
| 453 | + &self, | |
| 454 | + repo_id: &str, | |
| 455 | + title: &str, | |
| 456 | + description: &str, | |
| 457 | + due_on: Option<&str>, | |
| 458 | + ) -> Result<Milestone> { | |
| 459 | + let title = validate_title(title)?; | |
| 460 | + validate_body(description)?; | |
| 461 | + validate_due_on(due_on)?; | |
| 462 | + let milestone = Milestone { | |
| 463 | + id: new_id(), | |
| 464 | + repo_id: repo_id.to_string(), | |
| 465 | + title, | |
| 466 | + description: description.to_string(), | |
| 467 | + due_on: due_on.map(str::to_owned), | |
| 468 | + state: IssueState::Open, | |
| 469 | + created_at: now_stamp(), | |
| 470 | + closed_at: None, | |
| 471 | + open_issues: 0, | |
| 472 | + closed_issues: 0, | |
| 473 | + }; | |
| 474 | + let conn = self.inner.conn.lock().await; | |
| 475 | + conn.execute( | |
| 476 | + "INSERT INTO milestones (id, repo_id, title, title_folded, description, due_on, state, created_at) \ | |
| 477 | + VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'open', ?7)", | |
| 478 | + ( | |
| 479 | + milestone.id.clone(), | |
| 480 | + milestone.repo_id.clone(), | |
| 481 | + milestone.title.clone(), | |
| 482 | + normalize_name(&milestone.title), | |
| 483 | + milestone.description.clone(), | |
| 484 | + milestone.due_on.clone(), | |
| 485 | + milestone.created_at.clone(), | |
| 486 | + ), | |
| 487 | + ) | |
| 488 | + .await | |
| 489 | + .map_err(|e| insert_err(e, MILESTONE_TAKEN))?; | |
| 490 | + Ok(milestone) | |
| 491 | + } | |
| 492 | + | |
| 493 | + /// Updates a milestone. `closed_at` is set when it becomes closed and cleared when reopened. | |
| 494 | + /// `NotFound` if the milestone is not in `repo_id`. | |
| 495 | + pub async fn update_milestone( | |
| 496 | + &self, | |
| 497 | + repo_id: &str, | |
| 498 | + id: &str, | |
| 499 | + title: &str, | |
| 500 | + description: &str, | |
| 501 | + due_on: Option<&str>, | |
| 502 | + state: IssueState, | |
| 503 | + ) -> Result<()> { | |
| 504 | + let title = validate_title(title)?; | |
| 505 | + validate_body(description)?; | |
| 506 | + validate_due_on(due_on)?; | |
| 507 | + let conn = self.inner.conn.lock().await; | |
| 508 | + let n = conn | |
| 509 | + .execute( | |
| 510 | + "UPDATE milestones SET title = ?3, title_folded = ?4, description = ?5, due_on = ?6, \ | |
| 511 | + state = ?7, closed_at = CASE WHEN ?7 = 'closed' THEN COALESCE(closed_at, ?8) END \ | |
| 512 | + WHERE id = ?1 AND repo_id = ?2", | |
| 513 | + ( | |
| 514 | + id.to_string(), | |
| 515 | + repo_id.to_string(), | |
| 516 | + normalize_name(&title), | |
| 517 | + title, | |
| 518 | + description.to_string(), | |
| 519 | + due_on.map(str::to_owned), | |
| 520 | + state.as_str().to_string(), | |
| 521 | + now_stamp(), | |
| 522 | + ), | |
| 523 | + ) | |
| 524 | + .await | |
| 525 | + .map_err(|e| insert_err(e, MILESTONE_TAKEN))?; | |
| 526 | + if n == 0 { | |
| 527 | + return Err(Error::NotFound); | |
| 528 | + } | |
| 529 | + Ok(()) | |
| 530 | + } | |
| 531 | + | |
| 532 | + /// Deletes a milestone. Its issues stay, with `milestone_id` set to NULL first. `NotFound` | |
| 533 | + /// (and nothing changed) if the milestone is not in `repo_id`. | |
| 534 | + pub async fn delete_milestone(&self, repo_id: &str, id: &str) -> Result<()> { | |
| 535 | + let conn = self.inner.conn.lock().await; | |
| 536 | + let tx = conn.unchecked_transaction().await.map_err(db_err)?; | |
| 537 | + let outcome: Result<()> = async { | |
| 538 | + tx.execute( | |
| 539 | + "UPDATE issues SET milestone_id = NULL WHERE milestone_id = ?1 AND repo_id = ?2", | |
| 540 | + (id.to_string(), repo_id.to_string()), | |
| 541 | + ) | |
| 542 | + .await | |
| 543 | + .map_err(db_err)?; | |
| 544 | + let n = tx | |
| 545 | + .execute( | |
| 546 | + "DELETE FROM milestones WHERE id = ?1 AND repo_id = ?2", | |
| 547 | + (id.to_string(), repo_id.to_string()), | |
| 548 | + ) | |
| 549 | + .await | |
| 550 | + .map_err(db_err)?; | |
| 551 | + if n == 0 { | |
| 552 | + return Err(Error::NotFound); | |
| 553 | + } | |
| 554 | + Ok(()) | |
| 555 | + } | |
| 556 | + .await; | |
| 557 | + commit_or_rollback(tx, outcome).await | |
| 558 | + } | |
| 559 | + | |
| 560 | + /// Sets or clears the issue's milestone. The milestone must belong to the issue's repo | |
| 561 | + /// (`Invalid` otherwise). Records `demilestoned` for the old one and `milestoned` for the | |
| 562 | + /// new one, each with `{"title": ...}`. Setting the same value again changes nothing. | |
| 563 | + pub async fn set_issue_milestone( | |
| 564 | + &self, | |
| 565 | + issue_id: &str, | |
| 566 | + actor_id: &str, | |
| 567 | + milestone_id: Option<&str>, | |
| 568 | + ) -> Result<()> { | |
| 569 | + let conn = self.inner.conn.lock().await; | |
| 570 | + let tx = conn.unchecked_transaction().await.map_err(db_err)?; | |
| 571 | + let outcome: Result<()> = async { | |
| 572 | + let (repo_id, current_id, current_title) = fetch_opt( | |
| 573 | + &tx, | |
| 574 | + "SELECT i.repo_id, i.milestone_id, m.title FROM issues i \ | |
| 575 | + LEFT JOIN milestones m ON m.id = i.milestone_id WHERE i.id = ?1", | |
| 576 | + (issue_id.to_string(),), | |
| 577 | + |row| { | |
| 578 | + Ok(( | |
| 579 | + col_text(row, 0)?, | |
| 580 | + col_opt_text(row, 1)?, | |
| 581 | + col_opt_text(row, 2)?, | |
| 582 | + )) | |
| 583 | + }, | |
| 584 | + ) | |
| 585 | + .await? | |
| 586 | + .ok_or(Error::NotFound)?; | |
| 587 | + if current_id.as_deref() == milestone_id { | |
| 588 | + return Ok(()); | |
| 589 | + } | |
| 590 | + | |
| 591 | + let next_title = match milestone_id { | |
| 592 | + None => None, | |
| 593 | + Some(mid) => Some( | |
| 594 | + fetch_opt( | |
| 595 | + &tx, | |
| 596 | + "SELECT title FROM milestones WHERE id = ?1 AND repo_id = ?2", | |
| 597 | + (mid.to_string(), repo_id), | |
| 598 | + |row| col_text(row, 0), | |
| 599 | + ) | |
| 600 | + .await? | |
| 601 | + .ok_or_else(|| Error::Invalid("milestone is not in this repository".into()))?, | |
| 602 | + ), | |
| 603 | + }; | |
| 604 | + | |
| 605 | + tx.execute( | |
| 606 | + "UPDATE issues SET milestone_id = ?2 WHERE id = ?1", | |
| 607 | + (issue_id.to_string(), milestone_id.map(str::to_owned)), | |
| 608 | + ) | |
| 609 | + .await | |
| 610 | + .map_err(db_err)?; | |
| 611 | + if let Some(title) = current_title { | |
| 612 | + insert_event( | |
| 613 | + &tx, | |
| 614 | + issue_id, | |
| 615 | + Some(actor_id), | |
| 616 | + "demilestoned", | |
| 617 | + &json!({ "title": title }), | |
| 618 | + ) | |
| 619 | + .await?; | |
| 620 | + } | |
| 621 | + if let Some(title) = next_title { | |
| 622 | + insert_event( | |
| 623 | + &tx, | |
| 624 | + issue_id, | |
| 625 | + Some(actor_id), | |
| 626 | + "milestoned", | |
| 627 | + &json!({ "title": title }), | |
| 628 | + ) | |
| 629 | + .await?; | |
| 630 | + } | |
| 631 | + Ok(()) | |
| 632 | + } | |
| 633 | + .await; | |
| 634 | + commit_or_rollback(tx, outcome).await | |
| 635 | + } | |
| 636 | +} | |
| 637 | + | |
| 638 | +// helpers | |
| 639 | + | |
| 640 | +fn map_label(row: &Row) -> Result<Label> { | |
| 641 | + Ok(Label { | |
| 642 | + id: col_text(row, 0)?, | |
| 643 | + repo_id: col_text(row, 1)?, | |
| 644 | + name: col_text(row, 2)?, | |
| 645 | + color: col_text(row, 3)?, | |
| 646 | + description: col_text(row, 4)?, | |
| 647 | + }) | |
| 648 | +} | |
| 649 | + | |
| 650 | +fn map_milestone(row: &Row) -> Result<Milestone> { | |
| 651 | + Ok(Milestone { | |
| 652 | + id: col_text(row, 0)?, | |
| 653 | + repo_id: col_text(row, 1)?, | |
| 654 | + title: col_text(row, 2)?, | |
| 655 | + description: col_text(row, 3)?, | |
| 656 | + due_on: col_opt_text(row, 4)?, | |
| 657 | + state: col_text(row, 5)?.parse()?, | |
| 658 | + created_at: col_text(row, 6)?, | |
| 659 | + closed_at: col_opt_text(row, 7)?, | |
| 660 | + open_issues: col_int(row, 8)?, | |
| 661 | + closed_issues: col_int(row, 9)?, | |
| 662 | + }) | |
| 663 | +} | |
| 664 | + | |
| 665 | +/// `YYYY-MM-DD` and a real calendar date. The shape check rejects inputs such as a full | |
| 666 | +/// datetime that `Date::from_str` might otherwise accept. | |
| 667 | +fn validate_due_on(due_on: Option<&str>) -> Result<()> { | |
| 668 | + let Some(s) = due_on else { | |
| 669 | + return Ok(()); | |
| 670 | + }; | |
| 671 | + let shaped = s.len() == 10 | |
| 672 | + && s.bytes().enumerate().all(|(i, b)| { | |
| 673 | + if i == 4 || i == 7 { | |
| 674 | + b == b'-' | |
| 675 | + } else { | |
| 676 | + b.is_ascii_digit() | |
| 677 | + } | |
| 678 | + }); | |
| 679 | + if shaped && s.parse::<Date>().is_ok() { | |
| 680 | + Ok(()) | |
| 681 | + } else { | |
| 682 | + Err(Error::Invalid("due date must be YYYY-MM-DD".into())) | |
| 683 | + } | |
| 684 | +} | |
| 685 | + | |
| 686 | +/// The repo of an issue, or `NotFound`. | |
| 687 | +async fn issue_repo_id(conn: &Connection, issue_id: &str) -> Result<String> { | |
| 688 | + fetch_opt( | |
| 689 | + conn, | |
| 690 | + "SELECT repo_id FROM issues WHERE id = ?1", | |
| 691 | + (issue_id.to_string(),), | |
| 692 | + |row| col_text(row, 0), | |
| 693 | + ) | |
| 694 | + .await? | |
| 695 | + .ok_or(Error::NotFound) | |
| 696 | +} | |
| 697 | + | |
| 698 | +/// Inserts an issue event on the given connection or transaction. Used inside transactions, | |
| 699 | +/// where `Store::add_event` would deadlock on the connection lock. | |
| 700 | +async fn insert_event( | |
| 701 | + conn: &Connection, | |
| 702 | + issue_id: &str, | |
| 703 | + actor_id: Option<&str>, | |
| 704 | + kind: &str, | |
| 705 | + data: &Value, | |
| 706 | +) -> Result<()> { | |
| 707 | + conn.execute( | |
| 708 | + "INSERT INTO issue_events (id, issue_id, actor_id, kind, data, created_at) \ | |
| 709 | + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", | |
| 710 | + ( | |
| 711 | + new_id(), | |
| 712 | + issue_id.to_string(), | |
| 713 | + actor_id.map(str::to_owned), | |
| 714 | + kind.to_string(), | |
| 715 | + data.to_string(), | |
| 716 | + now_stamp(), | |
| 717 | + ), | |
| 718 | + ) | |
| 719 | + .await | |
| 720 | + .map_err(db_err)?; | |
| 721 | + Ok(()) | |
| 722 | +} | |
| 723 | + | |
| 724 | +/// Commits when the work succeeded, otherwise rolls back and returns the error. | |
| 725 | +async fn commit_or_rollback<T>(tx: Transaction<'_>, outcome: Result<T>) -> Result<T> { | |
| 726 | + match outcome { | |
| 727 | + Ok(value) => { | |
| 728 | + tx.commit().await.map_err(db_err)?; | |
| 729 | + Ok(value) | |
| 730 | + } | |
| 731 | + Err(e) => { | |
| 732 | + let _ = tx.rollback().await; | |
| 733 | + Err(e) | |
| 734 | + } | |
| 735 | + } | |
| 736 | +} | |
| 737 | + | |
| 738 | +#[cfg(test)] | |
| 739 | +mod tests { | |
| 740 | + use super::*; | |
| 741 | + use crate::issues::{IssueEvent, StateReason}; | |
| 742 | + use crate::model::{Repo, Role, Visibility}; | |
| 743 | + | |
| 744 | + struct Fixture { | |
| 745 | + _dir: tempfile::TempDir, | |
| 746 | + store: Store, | |
| 747 | + alice: User, | |
| 748 | + bob: User, | |
| 749 | + repo: Repo, | |
| 750 | + other: Repo, | |
| 751 | + } | |
| 752 | + | |
| 753 | + async fn fixture() -> Fixture { | |
| 754 | + let dir = tempfile::tempdir().unwrap(); | |
| 755 | + let store = Store::open(&dir.path().join("forge.db")).await.unwrap(); | |
| 756 | + let alice = store | |
| 757 | + .create_user("Alice", None, "hash", false) | |
| 758 | + .await | |
| 759 | + .unwrap(); | |
| 760 | + let bob = store.create_user("Bob", None, "hash", false).await.unwrap(); | |
| 761 | + let repo = store | |
| 762 | + .create_repo(&alice.id, "repo", "", Visibility::Public, "main") | |
| 763 | + .await | |
| 764 | + .unwrap(); | |
| 765 | + let other = store | |
| 766 | + .create_repo(&alice.id, "other", "", Visibility::Public, "main") | |
| 767 | + .await | |
| 768 | + .unwrap(); | |
| 769 | + Fixture { | |
| 770 | + _dir: dir, | |
| 771 | + store, | |
| 772 | + alice, | |
| 773 | + bob, | |
| 774 | + repo, | |
| 775 | + other, | |
| 776 | + } | |
| 777 | + } | |
| 778 | + | |
| 779 | + /// Data of every event of `kind`. | |
| 780 | + fn data_of(events: &[IssueEvent], kind: &str) -> Vec<Value> { | |
| 781 | + events | |
| 782 | + .iter() | |
| 783 | + .filter(|e| e.kind == kind) | |
| 784 | + .map(|e| e.data.clone()) | |
| 785 | + .collect() | |
| 786 | + } | |
| 787 | + | |
| 788 | + fn names(labels: &[Label]) -> Vec<&str> { | |
| 789 | + labels.iter().map(|l| l.name.as_str()).collect() | |
| 790 | + } | |
| 791 | + | |
| 792 | + #[tokio::test] | |
| 793 | + async fn create_label_normalizes_and_rejects_duplicates() { | |
| 794 | + let fx = fixture().await; | |
| 795 | + let label = fx | |
| 796 | + .store | |
| 797 | + .create_label(&fx.repo.id, " Bug ", "#FF0000", "broken") | |
| 798 | + .await | |
| 799 | + .unwrap(); | |
| 800 | + assert_eq!(label.name, "Bug"); | |
| 801 | + assert_eq!(label.color, "#ff0000"); | |
| 802 | + assert_eq!(label.description, "broken"); | |
| 803 | + assert!(matches!( | |
| 804 | + fx.store | |
| 805 | + .create_label(&fx.repo.id, "BUG", "#00ff00", "") | |
| 806 | + .await, | |
| 807 | + Err(Error::Conflict(_)) | |
| 808 | + )); | |
| 809 | + assert!( | |
| 810 | + fx.store | |
| 811 | + .create_label(&fx.repo.id, "bad", "red", "") | |
| 812 | + .await | |
| 813 | + .is_err() | |
| 814 | + ); | |
| 815 | + // The same name in another repo is a different label. | |
| 816 | + fx.store | |
| 817 | + .create_label(&fx.other.id, "bug", "#000000", "") | |
| 818 | + .await | |
| 819 | + .unwrap(); | |
| 820 | + } | |
| 821 | + | |
| 822 | + #[tokio::test] | |
| 823 | + async fn create_default_labels_adds_nine_and_is_idempotent() { | |
| 824 | + let fx = fixture().await; | |
| 825 | + let s = &fx.store; | |
| 826 | + assert_eq!(s.create_default_labels(&fx.repo.id).await.unwrap(), 9); | |
| 827 | + let labels = s.list_labels(&fx.repo.id).await.unwrap(); | |
| 828 | + assert_eq!(labels.len(), 9); | |
| 829 | + let bug = labels.iter().find(|l| l.name == "bug").unwrap(); | |
| 830 | + assert_eq!(bug.color, "#d73a4a"); | |
| 831 | + assert_eq!(bug.description, "Something isn't working"); | |
| 832 | + // A second run creates nothing and keeps the existing rows. | |
| 833 | + assert_eq!(s.create_default_labels(&fx.repo.id).await.unwrap(), 0); | |
| 834 | + assert_eq!(s.list_labels(&fx.repo.id).await.unwrap().len(), 9); | |
| 835 | + } | |
| 836 | + | |
| 837 | + #[tokio::test] | |
| 838 | + async fn create_default_labels_skips_names_already_taken() { | |
| 839 | + let fx = fixture().await; | |
| 840 | + let s = &fx.store; | |
| 841 | + s.create_label(&fx.repo.id, "BUG", "#000000", "mine") | |
| 842 | + .await | |
| 843 | + .unwrap(); | |
| 844 | + assert_eq!(s.create_default_labels(&fx.repo.id).await.unwrap(), 8); | |
| 845 | + let labels = s.list_labels(&fx.repo.id).await.unwrap(); | |
| 846 | + assert_eq!(labels.len(), 9); | |
| 847 | + let bug = labels.iter().find(|l| l.name == "BUG").unwrap(); | |
| 848 | + assert_eq!(bug.color, "#000000"); | |
| 849 | + assert_eq!(bug.description, "mine"); | |
| 850 | + } | |
| 851 | + | |
| 852 | + #[tokio::test] | |
| 853 | + async fn list_labels_is_sorted_and_scoped() { | |
| 854 | + let fx = fixture().await; | |
| 855 | + let s = &fx.store; | |
| 856 | + s.create_label(&fx.repo.id, "b", "#111111", "") | |
| 857 | + .await | |
| 858 | + .unwrap(); | |
| 859 | + s.create_label(&fx.repo.id, "A", "#111111", "") | |
| 860 | + .await | |
| 861 | + .unwrap(); | |
| 862 | + s.create_label(&fx.other.id, "c", "#111111", "") | |
| 863 | + .await | |
| 864 | + .unwrap(); | |
| 865 | + assert_eq!( | |
| 866 | + names(&s.list_labels(&fx.repo.id).await.unwrap()), | |
| 867 | + ["A", "b"] | |
| 868 | + ); | |
| 869 | + assert_eq!(names(&s.list_labels(&fx.other.id).await.unwrap()), ["c"]); | |
| 870 | + } | |
| 871 | + | |
| 872 | + #[tokio::test] | |
| 873 | + async fn update_label_checks_conflicts_and_scope() { | |
| 874 | + let fx = fixture().await; | |
| 875 | + let s = &fx.store; | |
| 876 | + let bug = s | |
| 877 | + .create_label(&fx.repo.id, "bug", "#111111", "") | |
| 878 | + .await | |
| 879 | + .unwrap(); | |
| 880 | + s.create_label(&fx.repo.id, "docs", "#222222", "") | |
| 881 | + .await | |
| 882 | + .unwrap(); | |
| 883 | + let foreign = s | |
| 884 | + .create_label(&fx.other.id, "x", "#333333", "") | |
| 885 | + .await | |
| 886 | + .unwrap(); | |
| 887 | + | |
| 888 | + assert!(matches!( | |
| 889 | + s.update_label(&fx.repo.id, &bug.id, "DOCS", "#111111", "") | |
| 890 | + .await, | |
| 891 | + Err(Error::Conflict(_)) | |
| 892 | + )); | |
| 893 | + assert!(matches!( | |
| 894 | + s.update_label(&fx.repo.id, &foreign.id, "y", "#111111", "") | |
| 895 | + .await, | |
| 896 | + Err(Error::NotFound) | |
| 897 | + )); | |
| 898 | + s.update_label(&fx.repo.id, &bug.id, "Defect", "#00FF00", "renamed") | |
| 899 | + .await | |
| 900 | + .unwrap(); | |
| 901 | + let labels = s.list_labels(&fx.repo.id).await.unwrap(); | |
| 902 | + assert_eq!(names(&labels), ["Defect", "docs"]); | |
| 903 | + assert_eq!(labels[0].color, "#00ff00"); | |
| 904 | + assert_eq!(labels[0].description, "renamed"); | |
| 905 | + // The foreign label was not touched. | |
| 906 | + assert_eq!(names(&s.list_labels(&fx.other.id).await.unwrap()), ["x"]); | |
| 907 | + } | |
| 908 | + | |
| 909 | + #[tokio::test] | |
| 910 | + async fn delete_label_removes_links_and_is_scoped() { | |
| 911 | + let fx = fixture().await; | |
| 912 | + let s = &fx.store; | |
| 913 | + let bug = s | |
| 914 | + .create_label(&fx.repo.id, "bug", "#111111", "") | |
| 915 | + .await | |
| 916 | + .unwrap(); | |
| 917 | + let issue = s | |
| 918 | + .create_issue(&fx.repo.id, &fx.alice.id, "t", "") | |
| 919 | + .await | |
| 920 | + .unwrap(); | |
| 921 | + s.set_issue_labels(&issue.id, &fx.alice.id, std::slice::from_ref(&bug.id)) | |
| 922 | + .await | |
| 923 | + .unwrap(); | |
| 924 | + | |
| 925 | + assert!(matches!( | |
| 926 | + s.delete_label(&fx.other.id, &bug.id).await, | |
| 927 | + Err(Error::NotFound) | |
| 928 | + )); | |
| 929 | + assert_eq!(s.issue_labels(&issue.id).await.unwrap().len(), 1); | |
| 930 | + | |
| 931 | + s.delete_label(&fx.repo.id, &bug.id).await.unwrap(); | |
| 932 | + assert!(s.issue_labels(&issue.id).await.unwrap().is_empty()); | |
| 933 | + assert!(s.list_labels(&fx.repo.id).await.unwrap().is_empty()); | |
| 934 | + } | |
| 935 | + | |
| 936 | + #[tokio::test] | |
| 937 | + async fn set_issue_labels_diffs_and_records_events() { | |
| 938 | + let fx = fixture().await; | |
| 939 | + let s = &fx.store; | |
| 940 | + let issue = s | |
| 941 | + .create_issue(&fx.repo.id, &fx.alice.id, "t", "") | |
| 942 | + .await | |
| 943 | + .unwrap(); | |
| 944 | + let a = s | |
| 945 | + .create_label(&fx.repo.id, "a", "#111111", "") | |
| 946 | + .await | |
| 947 | + .unwrap(); | |
| 948 | + let b = s | |
| 949 | + .create_label(&fx.repo.id, "b", "#111111", "") | |
| 950 | + .await | |
| 951 | + .unwrap(); | |
| 952 | + let c = s | |
| 953 | + .create_label(&fx.repo.id, "c", "#111111", "") | |
| 954 | + .await | |
| 955 | + .unwrap(); | |
| 956 | + | |
| 957 | + s.set_issue_labels(&issue.id, &fx.alice.id, &[a.id.clone(), b.id.clone()]) | |
| 958 | + .await | |
| 959 | + .unwrap(); | |
| 960 | + let events = s.list_events(&issue.id).await.unwrap(); | |
| 961 | + let labeled = data_of(&events, "labeled"); | |
| 962 | + assert_eq!(labeled.len(), 2); | |
| 963 | + assert!(labeled.contains(&json!({"label": "a"}))); | |
| 964 | + assert!(labeled.contains(&json!({"label": "b"}))); | |
| 965 | + assert!(data_of(&events, "unlabeled").is_empty()); | |
| 966 | + | |
| 967 | + s.set_issue_labels(&issue.id, &fx.alice.id, &[b.id.clone(), c.id.clone()]) | |
| 968 | + .await | |
| 969 | + .unwrap(); | |
| 970 | + let events = s.list_events(&issue.id).await.unwrap(); | |
| 971 | + assert_eq!(data_of(&events, "unlabeled"), [json!({"label": "a"})]); | |
| 972 | + assert_eq!(data_of(&events, "labeled").len(), 3); | |
| 973 | + assert_eq!(names(&s.issue_labels(&issue.id).await.unwrap()), ["b", "c"]); | |
| 974 | + | |
| 975 | + // Setting the same set again changes nothing and records nothing. | |
| 976 | + s.set_issue_labels(&issue.id, &fx.alice.id, &[c.id.clone(), b.id.clone()]) | |
| 977 | + .await | |
| 978 | + .unwrap(); | |
| 979 | + let events = s.list_events(&issue.id).await.unwrap(); | |
| 980 | + assert_eq!(data_of(&events, "labeled").len(), 3); | |
| 981 | + assert_eq!(data_of(&events, "unlabeled").len(), 1); | |
| 982 | + } | |
| 983 | + | |
| 984 | + #[tokio::test] | |
| 985 | + async fn set_issue_labels_rejects_foreign_label_without_partial_write() { | |
| 986 | + let fx = fixture().await; | |
| 987 | + let s = &fx.store; | |
| 988 | + let issue = s | |
| 989 | + .create_issue(&fx.repo.id, &fx.alice.id, "t", "") | |
| 990 | + .await | |
| 991 | + .unwrap(); | |
| 992 | + let a = s | |
| 993 | + .create_label(&fx.repo.id, "a", "#111111", "") | |
| 994 | + .await | |
| 995 | + .unwrap(); | |
| 996 | + let b = s | |
| 997 | + .create_label(&fx.repo.id, "b", "#111111", "") | |
| 998 | + .await | |
| 999 | + .unwrap(); | |
| 1000 | + let foreign = s | |
| 1001 | + .create_label(&fx.other.id, "x", "#111111", "") | |
| 1002 | + .await | |
| 1003 | + .unwrap(); | |
| 1004 | + s.set_issue_labels(&issue.id, &fx.alice.id, std::slice::from_ref(&a.id)) | |
| 1005 | + .await | |
| 1006 | + .unwrap(); | |
| 1007 | + | |
| 1008 | + let err = s | |
| 1009 | + .set_issue_labels(&issue.id, &fx.alice.id, &[b.id.clone(), foreign.id.clone()]) | |
| 1010 | + .await; | |
| 1011 | + assert!(matches!(err, Err(Error::Invalid(_)))); | |
| 1012 | + assert_eq!(names(&s.issue_labels(&issue.id).await.unwrap()), ["a"]); | |
| 1013 | + let events = s.list_events(&issue.id).await.unwrap(); | |
| 1014 | + assert_eq!(data_of(&events, "labeled").len(), 1); | |
| 1015 | + | |
| 1016 | + assert!(matches!( | |
| 1017 | + s.set_issue_labels("missing", &fx.alice.id, &[]).await, | |
| 1018 | + Err(Error::NotFound) | |
| 1019 | + )); | |
| 1020 | + } | |
| 1021 | + | |
| 1022 | + #[tokio::test] | |
| 1023 | + async fn set_issue_assignees_dedups_resolves_and_records_events() { | |
| 1024 | + let fx = fixture().await; | |
| 1025 | + let s = &fx.store; | |
| 1026 | + let issue = s | |
| 1027 | + .create_issue(&fx.repo.id, &fx.alice.id, "t", "") | |
| 1028 | + .await | |
| 1029 | + .unwrap(); | |
| 1030 | + let input = ["bob".to_string(), "BOB".to_string(), " Alice ".to_string()]; | |
| 1031 | + s.set_issue_assignees(&issue.id, &fx.alice.id, &input) | |
| 1032 | + .await | |
| 1033 | + .unwrap(); | |
| 1034 | + | |
| 1035 | + let assignees = s.issue_assignees(&issue.id).await.unwrap(); | |
| 1036 | + let display: Vec<&str> = assignees.iter().map(|(_, n)| n.as_str()).collect(); | |
| 1037 | + assert_eq!(display, ["Alice", "Bob"]); | |
| 1038 | + let events = s.list_events(&issue.id).await.unwrap(); | |
| 1039 | + assert_eq!(data_of(&events, "assigned").len(), 2); | |
| 1040 | + assert!(data_of(&events, "assigned").contains(&json!({"user": "Bob"}))); | |
| 1041 | + | |
| 1042 | + s.set_issue_assignees(&issue.id, &fx.alice.id, &["alice".to_string()]) | |
| 1043 | + .await | |
| 1044 | + .unwrap(); | |
| 1045 | + let assignees = s.issue_assignees(&issue.id).await.unwrap(); | |
| 1046 | + assert_eq!(assignees.len(), 1); | |
| 1047 | + assert_eq!(assignees[0].0, fx.alice.id); | |
| 1048 | + let events = s.list_events(&issue.id).await.unwrap(); | |
| 1049 | + assert_eq!(data_of(&events, "unassigned"), [json!({"user": "Bob"})]); | |
| 1050 | + } | |
| 1051 | + | |
| 1052 | + #[tokio::test] | |
| 1053 | + async fn set_issue_assignees_enforces_limit_existence_and_read_access() { | |
| 1054 | + let fx = fixture().await; | |
| 1055 | + let s = &fx.store; | |
| 1056 | + let issue = s | |
| 1057 | + .create_issue(&fx.repo.id, &fx.alice.id, "t", "") | |
| 1058 | + .await | |
| 1059 | + .unwrap(); | |
| 1060 | + | |
| 1061 | + let mut usernames = Vec::new(); | |
| 1062 | + for i in 0..11 { | |
| 1063 | + let name = format!("user{i}"); | |
| 1064 | + s.create_user(&name, None, "hash", false).await.unwrap(); | |
| 1065 | + usernames.push(name); | |
| 1066 | + } | |
| 1067 | + assert!(matches!( | |
| 1068 | + s.set_issue_assignees(&issue.id, &fx.alice.id, &usernames) | |
| 1069 | + .await, | |
| 1070 | + Err(Error::Invalid(_)) | |
| 1071 | + )); | |
| 1072 | + s.set_issue_assignees(&issue.id, &fx.alice.id, &usernames[..10]) | |
| 1073 | + .await | |
| 1074 | + .unwrap(); | |
| 1075 | + assert_eq!(s.issue_assignees(&issue.id).await.unwrap().len(), 10); | |
| 1076 | + | |
| 1077 | + assert!(matches!( | |
| 1078 | + s.set_issue_assignees(&issue.id, &fx.alice.id, &["ghost".to_string()]) | |
| 1079 | + .await, | |
| 1080 | + Err(Error::Invalid(_)) | |
| 1081 | + )); | |
| 1082 | + | |
| 1083 | + // Bob has no role on a private repo, so he cannot be assigned there. | |
| 1084 | + let secret = s | |
| 1085 | + .create_repo(&fx.alice.id, "secret", "", Visibility::Private, "main") | |
| 1086 | + .await | |
| 1087 | + .unwrap(); | |
| 1088 | + let secret_issue = s | |
| 1089 | + .create_issue(&secret.id, &fx.alice.id, "t", "") | |
| 1090 | + .await | |
| 1091 | + .unwrap(); | |
| 1092 | + let bob = ["bob".to_string()]; | |
| 1093 | + assert!(matches!( | |
| 1094 | + s.set_issue_assignees(&secret_issue.id, &fx.alice.id, &bob) | |
| 1095 | + .await, | |
| 1096 | + Err(Error::Invalid(_)) | |
| 1097 | + )); | |
| 1098 | + s.set_collaborator(&secret.id, &fx.bob.id, Role::Read) | |
| 1099 | + .await | |
| 1100 | + .unwrap(); | |
| 1101 | + s.set_issue_assignees(&secret_issue.id, &fx.alice.id, &bob) | |
| 1102 | + .await | |
| 1103 | + .unwrap(); | |
| 1104 | + assert_eq!(s.issue_assignees(&secret_issue.id).await.unwrap().len(), 1); | |
| 1105 | + } | |
| 1106 | + | |
| 1107 | + #[tokio::test] | |
| 1108 | + async fn milestones_create_validate_and_count_in_one_listing() { | |
| 1109 | + let fx = fixture().await; | |
| 1110 | + let s = &fx.store; | |
| 1111 | + let v1 = s | |
| 1112 | + .create_milestone(&fx.repo.id, "v1", "first", Some("2026-12-31")) | |
| 1113 | + .await | |
| 1114 | + .unwrap(); | |
| 1115 | + assert_eq!(v1.due_on.as_deref(), Some("2026-12-31")); | |
| 1116 | + for bad in ["2026/12/31", "2026-02-30", "2026-1-5", "2026-12-31T00:00"] { | |
| 1117 | + assert!(matches!( | |
| 1118 | + s.create_milestone(&fx.repo.id, "x", "", Some(bad)).await, | |
| 1119 | + Err(Error::Invalid(_)) | |
| 1120 | + )); | |
| 1121 | + } | |
| 1122 | + assert!(matches!( | |
| 1123 | + s.create_milestone(&fx.repo.id, "V1", "", None).await, | |
| 1124 | + Err(Error::Conflict(_)) | |
| 1125 | + )); | |
| 1126 | + | |
| 1127 | + let i1 = s | |
| 1128 | + .create_issue(&fx.repo.id, &fx.alice.id, "a", "") | |
| 1129 | + .await | |
| 1130 | + .unwrap(); | |
| 1131 | + let i2 = s | |
| 1132 | + .create_issue(&fx.repo.id, &fx.alice.id, "b", "") | |
| 1133 | + .await | |
| 1134 | + .unwrap(); | |
| 1135 | + let i3 = s | |
| 1136 | + .create_issue(&fx.repo.id, &fx.alice.id, "c", "") | |
| 1137 | + .await | |
| 1138 | + .unwrap(); | |
| 1139 | + s.set_issue_milestone(&i1.id, &fx.alice.id, Some(&v1.id)) | |
| 1140 | + .await | |
| 1141 | + .unwrap(); | |
| 1142 | + s.set_issue_milestone(&i2.id, &fx.alice.id, Some(&v1.id)) | |
| 1143 | + .await | |
| 1144 | + .unwrap(); | |
| 1145 | + s.set_issue_milestone(&i3.id, &fx.alice.id, None) | |
| 1146 | + .await | |
| 1147 | + .unwrap(); | |
| 1148 | + s.set_issue_state( | |
| 1149 | + &i1.id, | |
| 1150 | + Some(&fx.alice.id), | |
| 1151 | + IssueState::Closed, | |
| 1152 | + Some(StateReason::Completed), | |
| 1153 | + json!({}), | |
| 1154 | + ) | |
| 1155 | + .await | |
| 1156 | + .unwrap(); | |
| 1157 | + | |
| 1158 | + let all = s.list_milestones(&fx.repo.id, None).await.unwrap(); | |
| 1159 | + assert_eq!(all.len(), 1); | |
| 1160 | + assert_eq!((all[0].open_issues, all[0].closed_issues), (1, 1)); | |
| 1161 | + assert!( | |
| 1162 | + s.list_milestones(&fx.repo.id, Some(IssueState::Closed)) | |
| 1163 | + .await | |
| 1164 | + .unwrap() | |
| 1165 | + .is_empty() | |
| 1166 | + ); | |
| 1167 | + assert_eq!( | |
| 1168 | + s.list_milestones(&fx.repo.id, Some(IssueState::Open)) | |
| 1169 | + .await | |
| 1170 | + .unwrap() | |
| 1171 | + .len(), | |
| 1172 | + 1 | |
| 1173 | + ); | |
| 1174 | + | |
| 1175 | + let got = s | |
| 1176 | + .milestone_by_id(&fx.repo.id, &v1.id) | |
| 1177 | + .await | |
| 1178 | + .unwrap() | |
| 1179 | + .unwrap(); | |
| 1180 | + assert_eq!((got.open_issues, got.closed_issues), (1, 1)); | |
| 1181 | + assert!( | |
| 1182 | + s.milestone_by_id(&fx.other.id, &v1.id) | |
| 1183 | + .await | |
| 1184 | + .unwrap() | |
| 1185 | + .is_none() | |
| 1186 | + ); | |
| 1187 | + } | |
| 1188 | + | |
| 1189 | + #[tokio::test] | |
| 1190 | + async fn update_milestone_sets_closed_at_and_is_scoped() { | |
| 1191 | + let fx = fixture().await; | |
| 1192 | + let s = &fx.store; | |
| 1193 | + let v1 = s | |
| 1194 | + .create_milestone(&fx.repo.id, "v1", "", Some("2026-12-31")) | |
| 1195 | + .await | |
| 1196 | + .unwrap(); | |
| 1197 | + assert!(matches!( | |
| 1198 | + s.update_milestone(&fx.other.id, &v1.id, "v1", "", None, IssueState::Open) | |
| 1199 | + .await, | |
| 1200 | + Err(Error::NotFound) | |
| 1201 | + )); | |
| 1202 | + | |
| 1203 | + s.update_milestone( | |
| 1204 | + &fx.repo.id, | |
| 1205 | + &v1.id, | |
| 1206 | + "v1.0", | |
| 1207 | + "done", | |
| 1208 | + None, | |
| 1209 | + IssueState::Closed, | |
| 1210 | + ) | |
| 1211 | + .await | |
| 1212 | + .unwrap(); | |
| 1213 | + let closed = s | |
| 1214 | + .milestone_by_id(&fx.repo.id, &v1.id) | |
| 1215 | + .await | |
| 1216 | + .unwrap() | |
| 1217 | + .unwrap(); | |
| 1218 | + assert_eq!(closed.title, "v1.0"); | |
| 1219 | + assert_eq!(closed.description, "done"); | |
| 1220 | + assert!(closed.due_on.is_none()); | |
| 1221 | + assert!(matches!(closed.state, IssueState::Closed)); | |
| 1222 | + assert!(closed.closed_at.is_some()); | |
| 1223 | + | |
| 1224 | + s.update_milestone(&fx.repo.id, &v1.id, "v1.0", "done", None, IssueState::Open) | |
| 1225 | + .await | |
| 1226 | + .unwrap(); | |
| 1227 | + let reopened = s | |
| 1228 | + .milestone_by_id(&fx.repo.id, &v1.id) | |
| 1229 | + .await | |
| 1230 | + .unwrap() | |
| 1231 | + .unwrap(); | |
| 1232 | + assert!(matches!(reopened.state, IssueState::Open)); | |
| 1233 | + assert!(reopened.closed_at.is_none()); | |
| 1234 | + } | |
| 1235 | + | |
| 1236 | + #[tokio::test] | |
| 1237 | + async fn set_issue_milestone_checks_repo_and_records_events() { | |
| 1238 | + let fx = fixture().await; | |
| 1239 | + let s = &fx.store; | |
| 1240 | + let issue = s | |
| 1241 | + .create_issue(&fx.repo.id, &fx.alice.id, "t", "") | |
| 1242 | + .await | |
| 1243 | + .unwrap(); | |
| 1244 | + let v1 = s | |
| 1245 | + .create_milestone(&fx.repo.id, "v1", "", None) | |
| 1246 | + .await | |
| 1247 | + .unwrap(); | |
| 1248 | + let v2 = s | |
| 1249 | + .create_milestone(&fx.repo.id, "v2", "", None) | |
| 1250 | + .await | |
| 1251 | + .unwrap(); | |
| 1252 | + let foreign = s | |
| 1253 | + .create_milestone(&fx.other.id, "v9", "", None) | |
| 1254 | + .await | |
| 1255 | + .unwrap(); | |
| 1256 | + | |
| 1257 | + assert!(matches!( | |
| 1258 | + s.set_issue_milestone(&issue.id, &fx.alice.id, Some(&foreign.id)) | |
| 1259 | + .await, | |
| 1260 | + Err(Error::Invalid(_)) | |
| 1261 | + )); | |
| 1262 | + | |
| 1263 | + s.set_issue_milestone(&issue.id, &fx.alice.id, Some(&v1.id)) | |
| 1264 | + .await | |
| 1265 | + .unwrap(); | |
| 1266 | + let events = s.list_events(&issue.id).await.unwrap(); | |
| 1267 | + assert_eq!(data_of(&events, "milestoned"), [json!({"title": "v1"})]); | |
| 1268 | + | |
| 1269 | + s.set_issue_milestone(&issue.id, &fx.alice.id, Some(&v2.id)) | |
| 1270 | + .await | |
| 1271 | + .unwrap(); | |
| 1272 | + let events = s.list_events(&issue.id).await.unwrap(); | |
| 1273 | + assert_eq!(data_of(&events, "demilestoned"), [json!({"title": "v1"})]); | |
| 1274 | + assert_eq!(data_of(&events, "milestoned").len(), 2); | |
| 1275 | + | |
| 1276 | + s.set_issue_milestone(&issue.id, &fx.alice.id, None) | |
| 1277 | + .await | |
| 1278 | + .unwrap(); | |
| 1279 | + let events = s.list_events(&issue.id).await.unwrap(); | |
| 1280 | + assert_eq!(data_of(&events, "demilestoned").len(), 2); | |
| 1281 | + assert!(data_of(&events, "demilestoned").contains(&json!({"title": "v2"}))); | |
| 1282 | + | |
| 1283 | + // Clearing again is a no-op. | |
| 1284 | + s.set_issue_milestone(&issue.id, &fx.alice.id, None) | |
| 1285 | + .await | |
| 1286 | + .unwrap(); | |
| 1287 | + let events = s.list_events(&issue.id).await.unwrap(); | |
| 1288 | + assert_eq!(data_of(&events, "demilestoned").len(), 2); | |
| 1289 | + let stored = s | |
| 1290 | + .issue_by_number(&fx.repo.id, issue.number) | |
| 1291 | + .await | |
| 1292 | + .unwrap() | |
| 1293 | + .unwrap(); | |
| 1294 | + assert!(stored.milestone_id.is_none()); | |
| 1295 | + } | |
| 1296 | + | |
| 1297 | + #[tokio::test] | |
| 1298 | + async fn delete_milestone_clears_issue_links_and_is_scoped() { | |
| 1299 | + let fx = fixture().await; | |
| 1300 | + let s = &fx.store; | |
| 1301 | + let v1 = s | |
| 1302 | + .create_milestone(&fx.repo.id, "v1", "", None) | |
| 1303 | + .await | |
| 1304 | + .unwrap(); | |
| 1305 | + let issue = s | |
| 1306 | + .create_issue(&fx.repo.id, &fx.alice.id, "t", "") | |
| 1307 | + .await | |
| 1308 | + .unwrap(); | |
| 1309 | + s.set_issue_milestone(&issue.id, &fx.alice.id, Some(&v1.id)) | |
| 1310 | + .await | |
| 1311 | + .unwrap(); | |
| 1312 | + | |
| 1313 | + assert!(matches!( | |
| 1314 | + s.delete_milestone(&fx.other.id, &v1.id).await, | |
| 1315 | + Err(Error::NotFound) | |
| 1316 | + )); | |
| 1317 | + let kept = s | |
| 1318 | + .issue_by_number(&fx.repo.id, issue.number) | |
| 1319 | + .await | |
| 1320 | + .unwrap() | |
| 1321 | + .unwrap(); | |
| 1322 | + assert_eq!(kept.milestone_id.as_deref(), Some(v1.id.as_str())); | |
| 1323 | + |
crates/forge-core/src/store/lfs.rsDiff too large; view file
crates/forge-core/src/store/mail.rsDiff too large; view file
crates/forge-core/src/store/migrate.rsDiff too large; view file
crates/forge-core/src/store/mirrors.rsDiff too large; view file
crates/forge-core/src/store/mod.rsDiff too large; view file
crates/forge-core/src/store/notifications.rsDiff too large; view file
crates/forge-core/src/store/notifications_extra.rsDiff too large; view file
crates/forge-core/src/store/orgs.rsDiff too large; view file
crates/forge-core/src/store/pinned.rsDiff too large; view file
crates/forge-core/src/store/pulls.rsDiff too large; view file
crates/forge-core/src/store/pulls_list_extra.rsDiff too large; view file
crates/forge-core/src/store/push_recent.rsDiff too large; view file
crates/forge-core/src/store/reactions.rsDiff too large; view file
crates/forge-core/src/store/releases.rsDiff too large; view file
crates/forge-core/src/store/replication.rsDiff too large; view file
crates/forge-core/src/store/reviews.rsDiff too large; view file
crates/forge-core/src/store/signatures.rsDiff too large; view file
crates/forge-core/src/store/snapshot.rsDiff too large; view file
crates/forge-core/src/store/snippets.rsDiff too large; view file
crates/forge-core/src/store/transfers.rsDiff too large; view file
crates/forge-core/src/store/ui_prefs.rsDiff too large; view file
crates/forge-core/src/store/user_delete.rsDiff too large; view file
crates/forge-core/src/store/webhooks.rsDiff too large; view file
crates/forge-core/src/store/wiki.rsDiff too large; view file
crates/forge-core/src/time.rsDiff too large; view file
crates/forge-core/src/token.rsDiff too large; view file
crates/forge-core/src/totp.rsDiff too large; view file
crates/forge-core/src/tz.rsDiff too large; view file
crates/forge-core/src/webhooks.rsDiff too large; view file
crates/forge-core/tests/audit_store.rsDiff too large; view file
crates/forge-core/tests/read_pool.rsDiff too large; view file
crates/forge-core/tests/stars_store.rsDiff too large; view file
crates/forge-core/tests/store_basic.rsDiff too large; view file
crates/forge-git/Cargo.tomlDiff too large; view file
crates/forge-git/src/backup.rsDiff too large; view file
crates/forge-git/src/browse.rsDiff too large; view file
crates/forge-git/src/command.rsDiff too large; view file
crates/forge-git/src/error.rsDiff too large; view file
crates/forge-git/src/fsck.rsDiff too large; view file
crates/forge-git/src/history.rsDiff too large; view file
crates/forge-git/src/lib.rsDiff too large; view file
crates/forge-git/src/maintenance.rsDiff too large; view file
crates/forge-git/src/mirror.rsDiff too large; view file
crates/forge-git/src/pktline.rsDiff too large; view file
crates/forge-git/src/pulls.rsDiff too large; view file
crates/forge-git/src/repo.rsDiff too large; view file
crates/forge-git/src/search.rsDiff too large; view file
crates/forge-git/src/service.rsDiff too large; view file
crates/forge-git/src/setup.rsDiff too large; view file
crates/forge-git/src/tags.rsDiff too large; view file
crates/forge-git/src/verify.rsDiff too large; view file
crates/forge-git/src/version.rsDiff too large; view file
crates/forge-git/src/wiki.rsDiff too large; view file
crates/forge-git/tests/browse.rsDiff too large; view file
crates/forge-git/tests/forge_git.rsDiff too large; view file
crates/forge-git/tests/history.rsDiff too large; view file
crates/forge-runner/Cargo.tomlDiff too large; view file
crates/forge-runner/src/artifacts.rsDiff too large; view file
crates/forge-runner/src/check.rsDiff too large; view file
crates/forge-runner/src/cli.rsDiff too large; view file
crates/forge-runner/src/client.rsDiff too large; view file
crates/forge-runner/src/config.rsDiff too large; view file
crates/forge-runner/src/executor.rsDiff too large; view file
crates/forge-runner/src/lib.rsDiff too large; view file
crates/forge-runner/src/logs.rsDiff too large; view file
crates/forge-runner/src/main.rsDiff too large; view file
crates/forge-runner/src/oci/helper.rsDiff too large; view file
crates/forge-runner/src/oci/mod.rsDiff too large; view file
crates/forge-runner/src/oci/pull.rsDiff too large; view file
crates/forge-runner/src/oci/store.rsDiff too large; view file
crates/forge-runner/src/oci/unpack.rsDiff too large; view file
crates/forge-runner/src/runner.rsDiff too large; view file
crates/forge-runner/src/sandbox/exec.rsDiff too large; view file
crates/forge-runner/src/sandbox/helper.rsDiff too large; view file
crates/forge-runner/src/sandbox/idmap.rsDiff too large; view file
crates/forge-runner/src/sandbox/mod.rsDiff too large; view file
crates/forge-runner/src/sandbox/seccomp.rsDiff too large; view file
crates/forge-runner/src/sandbox/spec.rsDiff too large; view file
crates/forge-runner/src/workspace.rsDiff too large; view file
crates/forge-runner/tests/builtin.rsDiff too large; view file
crates/forge-runner/tests/flow.rsDiff too large; view file
crates/forge-runner/tests/idmap.rsDiff too large; view file
crates/forge-runner/tests/sandbox.rsDiff too large; view file
crates/forge-runner/tests/unpack_subuid.rsDiff too large; view file
crates/forge-ssh/Cargo.tomlDiff too large; view file
crates/forge-ssh/src/backend.rsDiff too large; view file
crates/forge-ssh/src/error.rsDiff too large; view file
crates/forge-ssh/src/lib.rsDiff too large; view file
crates/forge-ssh/src/parse.rsDiff too large; view file
crates/forge-ssh/src/server.rsDiff too large; view file
crates/forge-ssh/tests/ssh_e2e.rsDiff too large; view file
crates/forge-web/Cargo.tomlDiff too large; view file
crates/forge-web/assets/nav.jsDiff too large; view file
crates/forge-web/build.rsDiff too large; view file
crates/forge-web/src/access.rsDiff too large; view file
crates/forge-web/src/admin_cli.rsDiff too large; view file
crates/forge-web/src/admin_client.rsDiff too large; view file
crates/forge-web/src/advisories/mod.rsDiff too large; view file
crates/forge-web/src/advisories/scan.rsDiff too large; view file
crates/forge-web/src/advisories/worker.rsDiff too large; view file
crates/forge-web/src/api/mod.rsDiff too large; view file
crates/forge-web/src/api/statuses.rsDiff too large; view file
crates/forge-web/src/audit.rsDiff too large; view file
crates/forge-web/src/auth.rsDiff too large; view file
crates/forge-web/src/backup/create.rsDiff too large; view file
crates/forge-web/src/backup/fsck.rsDiff too large; view file
crates/forge-web/src/backup/healthcheck.rsDiff too large; view file
crates/forge-web/src/backup/mod.rsDiff too large; view file
crates/forge-web/src/backup/restore.rsDiff too large; view file
crates/forge-web/src/backup/retention.rsDiff too large; view file
crates/forge-web/src/backup/scheduler.rsDiff too large; view file
crates/forge-web/src/backup/tests.rsDiff too large; view file
crates/forge-web/src/backup/verify.rsDiff too large; view file
crates/forge-web/src/blobs.rsDiff too large; view file
crates/forge-web/src/boot.rsDiff too large; view file
crates/forge-web/src/ci/api.rsDiff too large; view file
crates/forge-web/src/ci/api_tests.rsDiff too large; view file
crates/forge-web/src/ci/mod.rsDiff too large; view file
crates/forge-web/src/ci/reaper.rsDiff too large; view file
crates/forge-web/src/ci/schedule.rsDiff too large; view file
crates/forge-web/src/client_ip.rsDiff too large; view file
crates/forge-web/src/compression.rsDiff too large; view file
crates/forge-web/src/git_auth.rsDiff too large; view file
crates/forge-web/src/git_http.rsDiff too large; view file
crates/forge-web/src/hook_cmd.rsDiff too large; view file
crates/forge-web/src/i18n/admin.rsDiff too large; view file
crates/forge-web/src/i18n/code.rsDiff too large; view file
crates/forge-web/src/i18n/community.rsDiff too large; view file
crates/forge-web/src/i18n/integrations.rsDiff too large; view file
crates/forge-web/src/i18n/issues.rsDiff too large; view file
crates/forge-web/src/i18n/misc.rsDiff too large; view file
crates/forge-web/src/i18n/mod.rsDiff too large; view file
crates/forge-web/src/i18n/orgs.rsDiff too large; view file
crates/forge-web/src/i18n/pulls.rsDiff too large; view file
crates/forge-web/src/i18n/pulls_review.rsDiff too large; view file
crates/forge-web/src/i18n/releases_ci.rsDiff too large; view file
crates/forge-web/src/i18n/repo_admin.rsDiff too large; view file
crates/forge-web/src/internal.rsDiff too large; view file
crates/forge-web/src/lfs/gc.rsDiff too large; view file
crates/forge-web/src/lfs/mod.rsDiff too large; view file
crates/forge-web/src/lfs/route_tests.rsDiff too large; view file
crates/forge-web/src/lfs/routes.rsDiff too large; view file
crates/forge-web/src/lfs_pointer.rsDiff too large; view file
crates/forge-web/src/lib.rsDiff too large; view file
crates/forge-web/src/mail/account.rsDiff too large; view file
crates/forge-web/src/mail/log.rsDiff too large; view file
crates/forge-web/src/mail/mod.rsDiff too large; view file
crates/forge-web/src/mail/smtp.rsDiff too large; view file
crates/forge-web/src/mail/templates.rsDiff too large; view file
crates/forge-web/src/mail/worker.rsDiff too large; view file
crates/forge-web/src/main.rsDiff too large; view file
crates/forge-web/src/mirrors/mod.rsDiff too large; view file
crates/forge-web/src/mirrors/worker.rsDiff too large; view file
crates/forge-web/src/notify.rsDiff too large; view file
crates/forge-web/src/pages/admin_backups.rsDiff too large; view file
crates/forge-web/src/pages/admin_mail.rsDiff too large; view file
crates/forge-web/src/pages/admin_replication.rsDiff too large; view file
crates/forge-web/src/pages/admin_runners.rsDiff too large; view file
crates/forge-web/src/pages/admin_users.rsDiff too large; view file
crates/forge-web/src/pages/archive.rsDiff too large; view file
crates/forge-web/src/pages/assets.rsDiff too large; view file
crates/forge-web/src/pages/audit_log.rsDiff too large; view file
crates/forge-web/src/pages/avatars.rsDiff too large; view file
crates/forge-web/src/pages/blob.rsDiff too large; view file
crates/forge-web/src/pages/ci_job.rsDiff too large; view file
crates/forge-web/src/pages/ci_runs.rsDiff too large; view file
crates/forge-web/src/pages/ci_tests.rsDiff too large; view file
crates/forge-web/src/pages/code_search.rsDiff too large; view file
crates/forge-web/src/pages/commit.rsDiff too large; view file
crates/forge-web/src/pages/commits.rsDiff too large; view file
crates/forge-web/src/pages/compare.rsDiff too large; view file
crates/forge-web/src/pages/dashboard.rsDiff too large; view file
crates/forge-web/src/pages/discussion_actions.rsDiff too large; view file
crates/forge-web/src/pages/discussion_settings.rsDiff too large; view file
crates/forge-web/src/pages/discussion_view.rsDiff too large; view file
crates/forge-web/src/pages/discussions.rsDiff too large; view file
crates/forge-web/src/pages/edit_history.rsDiff too large; view file
crates/forge-web/src/pages/explore.rsDiff too large; view file
crates/forge-web/src/pages/forgot.rsDiff too large; view file
crates/forge-web/src/pages/fork.rsDiff too large; view file
crates/forge-web/src/pages/invites.rsDiff too large; view file
crates/forge-web/src/pages/issue_meta.rsDiff too large; view file
crates/forge-web/src/pages/issue_meta_tests.rsDiff too large; view file
crates/forge-web/src/pages/issue_parts.rsDiff too large; view file
crates/forge-web/src/pages/issue_pin.rsDiff too large; view file
crates/forge-web/src/pages/issue_pin_tests.rsDiff too large; view file
crates/forge-web/src/pages/issue_view.rsDiff too large; view file
crates/forge-web/src/pages/issues_list.rsDiff too large; view file
crates/forge-web/src/pages/labels_css.rsDiff too large; view file
crates/forge-web/src/pages/labels_page.rsDiff too large; view file
crates/forge-web/src/pages/login.rsDiff too large; view file
crates/forge-web/src/pages/media.rsDiff too large; view file
crates/forge-web/src/pages/milestones_page.rsDiff too large; view file
crates/forge-web/src/pages/mod.rsDiff too large; view file
crates/forge-web/src/pages/new_repo.rsDiff too large; view file
crates/forge-web/src/pages/notifications.rsDiff too large; view file
crates/forge-web/src/pages/org_ctx.rsDiff too large; view file
crates/forge-web/src/pages/org_members.rsDiff too large; view file
crates/forge-web/src/pages/org_new.rsDiff too large; view file
crates/forge-web/src/pages/org_settings.rsDiff too large; view file
crates/forge-web/src/pages/org_teams.rsDiff too large; view file
crates/forge-web/src/pages/profile.rsDiff too large; view file
crates/forge-web/src/pages/pull_branch.rsDiff too large; view file
crates/forge-web/src/pages/pull_files.rsDiff too large; view file
crates/forge-web/src/pages/pull_reviews.rsDiff too large; view file
crates/forge-web/src/pages/pull_view.rsDiff too large; view file
crates/forge-web/src/pages/pulls_list.rsDiff too large; view file
crates/forge-web/src/pages/pulls_merge.rsDiff too large; view file
crates/forge-web/src/pages/reactions.rsDiff too large; view file
crates/forge-web/src/pages/reactions_tests.rsDiff too large; view file
crates/forge-web/src/pages/refs.rsDiff too large; view file
crates/forge-web/src/pages/register.rsDiff too large; view file
crates/forge-web/src/pages/releases.rsDiff too large; view file
crates/forge-web/src/pages/releases_tests.rsDiff too large; view file
crates/forge-web/src/pages/repo.rsDiff too large; view file