Skip to content

Validation & Scoring Specifications

This page is the authoritative reference for what club enforces at two points in a package’s life:

  1. Publish time — every dart pub publish is validated against a large catalogue of structural, semantic, and security checks before the tarball is accepted.
  2. Analysis time — after publish, each version is scored by pana running inside an isolated subprocess with optional OS-level sandbox layers applied.

All numbers on this page stay in sync with what the server actually enforces.


Publish-Time Validation

Every archive goes through these checks, in order. The first failing layer rejects the upload with HTTP 400 Bad Request.

Archive transport

Applied before the tar is parsed. Protects against gzip bombs, empty uploads, and pre-flight corruption.

CheckLimit
Compressed archive size100 MB
Uncompressed size256 MB
Empty archiveRejected (0-byte file or 0-byte gzip stream)
Malformed gzip / trailing junkRejected

Tar structure

Applied during tar parsing. Each entry is scrutinised individually.

CheckBehaviour
Max file count in archive65 536
Max total entry size100 MB
Absolute paths (e.g. /etc/passwd)Rejected
.. in path (path traversal)Rejected
SymlinksRejected
Duplicate tar entriesRejected
Non-default mode bits (non-644 for files, non-exec bits for dirs)Rejected
Case-insensitive filename collisions (e.g. Foo.dart + foo.dart)Rejected — would break on Windows/macOS

pubspec.yaml

CheckLimit / Rule
Presence at archive rootRequired
Content size≤ 128 KB
YAML aliases / anchorsRejected — cannot round-trip to JSON
JSON serialisabilityMust round-trip via JSON encode/decode
Parse failureRetries in lenient mode before giving up
author and authors both presentRejected

Package name

CheckRule
Length1 – 64 characters
CharactersLowercase letters, digits, underscores only
Must start with a letter or underscore
Must not be a Dart reserved wordList: abstract, as, assert, … yield

Package version

CheckRule
Length≤ 64 characters
FormatStrict SemVer (MAJOR.MINOR.PATCH[-prerelease][+build])
All dependency constraintsMust also follow strict SemVer
Dart SDK constraintMust specify both min and max (neither any)
SDK lower bound <2.12.0 combined with upper ≥3.0.0Rejected (Dart 3 requires null-safety)
SDK allowing 4.0.0Rejected (Dart 4 doesn’t exist)

Description

CheckRule
PresenceRequired and non-empty after trimming
Max length512 characters
Max word length64 characters per whitespace-separated token
Emoji charactersAllowed on club · Rejected on pub.dev
Zalgo / combining diacritical marksRejected (two consecutive diacritics)
Boilerplate from dart create / flutter createAllowed on club · Rejected on pub.dev

URLs

Applied to homepage, repository, documentation, and issue_tracker fields.

CheckRule
Parse as URIMust succeed
SchemeMust be http or https
HostMust be non-empty and contain a .
Invalid host blocklistRejects example.com, example.org, example.net, google.com, none, and the www. variants

Dependencies

Applied to top-level dependencies only. dev_dependencies and dependency_overrides are exempt from the below.

CheckRule
Max direct dependencies100
Each dependency nameMust be a valid package name (same rules as top-level)
Git dependencies (git: key)Rejected
hosted: with url: not in the allowlistRejected
hosted.name ≠ dependency keyRejected
SDK dependencies (sdk: key)Allowed

Hosted-URL allowlist

A published package can legitimately depend on packages from trusted registries. The allowlist covers the common cases and includes this server’s own public URL so packages on the same club instance can depend on each other.

Dependency formExampleAllowed?
Default pub.dev (no hosted: key)http: ^1.0.0Yes
Explicit pub.devhosted: https://pub.devYes
Legacy pub.dev aliashosted: https://pub.dartlang.orgYes
This club serverhosted: url: https://packages.example.com (matching SERVER_URL)Yes
Any other hosthosted: https://other-registry.example.comRejected

URLs are compared origin-only — scheme + host + non-default port. Trailing slashes, letter case, and the default port (:443 for https) are normalised away, so https://pub.dev, https://pub.dev/, HTTPS://PUB.DEV, and https://pub.dev:443 all match. A non-default port (e.g. https://pub.dev:8443) does not match.

A rejection from this check names the unacceptable host and lists every allowed one so the publisher can see at a glance whether they meant to use a different form:

Package dependency foo is hosted on https://other-registry.example.com,
which is not in the allowed-hosts list
(https://pub.dev, https://pub.dartlang.org, https://packages.example.com).

Environment constraints

CheckRule
Allowed keys in environment:Only sdk, flutter, fuchsia
Any other keyRejected

Topics

Applied to the topics: list in pubspec.

CheckRule
Max topics5
Topic length2 – 32 characters
CharactersLowercase alphanumeric with single - (no double-dash)
First/last characterMust be alphanumeric
DuplicatesRejected

Funding

Applied to the funding: list in pubspec.

CheckRule
Must be a list of stringsOtherwise rejected
Each URL schemeMust be https
Each URL length≤ 255 characters

Screenshots

Structural rules applied to each entry in the pubspec screenshots: list:

CheckRule
Referenced path must exist in archiveRejected otherwise
Path must be normalised (no ./, no trailing slashes)Rejected if not
Description length10 – 200 characters
Description ZalgoRejected
Duplicate paths in the listRejected

Plus extraction caps applied to the screenshot bytes before they’re persisted:

CheckRule
Supported extensionspng, jpg, jpeg, gif, webp
Per-image size cap4 MiB
Screenshots per version10 (overflow silently ignored)

Build hooks

Applied to hook/*.dart files in the archive.

CheckRule
Allowed hook filenamesOnly hook/build.dart and hook/link.dart
Any other hook/*.dartRejected
Minimum SDK lower bound3.6.0-0

Flutter plugin schema

Only applied when flutter.plugin is present in pubspec.

CheckRule
Old (androidPackage/iosPrefix/pluginClass) + new (platforms:) in same pubspecRejected
Uses platforms: without flutter: ^1.10.0 constraintRejected
Uses platforms: without flutter: ^2.20.0 and no ios/ folderRejected

LICENSE

CheckRule on clubRule on pub.dev
PresenceNot requiredRequired
FilenameLICENSE at archive rootLICENSE at archive root
Non-empty after trimmingChecked if presentRequired
Generic TODO: Add your license here boilerplateChecked if presentRejected

README / CHANGELOG / example

FileFilename matchRequired on club
READMEREADME.md at archive rootNo
CHANGELOGCHANGELOG.md at archive rootNo (never required)
examplePriority list: example/example.mdexample/lib/main.dartexample/main.dartexample/lib/<pkg>.dartexample/<pkg>.dartexample/lib/<pkg>_example.dartexample/<pkg>_example.dartexample/lib/example.dartexample/example.dartexample/README.mdNo

publish_to field

Rule on clubRule on pub.dev
Not checkedMust be absent or none

club vs pub.dev defaults

club ships with a relaxed policy for private-registry reality. Every other rule on this page applies identically under both.

Checkclub defaultpub.dev
LICENSE file requiredNoYes
README file requiredNoYes
publish_to field checkedNoYes
Git dependencies forbiddenYesYes
Hosted dependencies outside the allowlist forbiddenYesYes
Default hosted-URL allowlistpub.dev, pub.dartlang.org, and this server’s own URLpub.dev, pub.dartlang.org
Emoji in description rejectedNoYes
Template description (e.g. A new Flutter project.) rejectedNoYes
Template README (leftover TODO: markers) rejectedNoYes
Mixed-case package name collision checkNoYes (legacy tables stripped)

The two strict hardening rules (Git dependencies and hosted-URL allowlist) stay on under both — they’re universally correct for a package repository. What differs is the allowlist contents: club appends the server’s own URL so publishers on the same instance can depend on each other.


Transport Limits Recap

One more table purely for ops planning — everything a single dart pub publish can push to disk:

DimensionLimitNotes
HTTP upload body100 MBEnforced by the publish endpoint in addition to the archive-layer check
Compressed tarball100 MB
Uncompressed tarball256 MB
Files in tarball65 536
pubspec.yaml128 KB
README / CHANGELOG / example / LICENSE content persisted256 KB each
Screenshot size4 MiB each
Screenshots per version10Overflow silently dropped

Scoring Subsystem

After publish, each version is handed to the scoring system, which runs pana on the tarball inside an isolated subprocess. The primary reason for subprocess (rather than in-process) execution is the trust model:

Architecture

┌──────────────────────────────────────────────┐
│ Server (scoring coordinator) │
│ queues jobs, spawns subprocesses │
│ │
│ parent writes: /tmp/…/job.json │
│ parent reads: /tmp/…/result.json │
│ parent drains: child stdout (pana noise) │
│ parent routes: child stderr → scoring.log │
└──────────────┬───────────────────────────────┘
│ spawns per job
┌──────────────────────────────────────────────┐
│ scoring subprocess (separate OS process, may │
│ run under setpriv + ulimits + custom prefix) │
│ │
│ • Reads job JSON │
│ • Extracts tarball to a temp dir │
│ • Invokes pana │
│ • Writes result JSON │
│ • Exits 0 on success, 1 on uncaught error │
└──────────────────────────────────────────────┘

IPC protocol

Files, not stdio. The child’s stdout is deliberately drained because dart pub get emits build-hook chatter there that would otherwise corrupt a stdout-based protocol.

DirectionChannelPayload
Parent → childFile path passed as argumentJob description as JSON
Child → parentFile path passed as argumentScoring result as JSON
Child → loggerStderr (line-buffered)Human-readable progress
Child → /dev/nullStdoutdart pub get build-hook output (discarded)

Concurrency

SettingDefaultNotes
Max in-flight subprocesses1Bump in configuration if host can handle more parallel analyses
QueueFIFO, in-memoryPending rows persisted in the DB so restarts resume cleanly
Duplicate-job suppressionActive-key setSame <pkg>@<ver> can’t be queued twice simultaneously

Timeouts

PhaseDuration
Dartdoc generation10 min
Full pana analysis (outer)50 min
Subprocess wall-clock55 min — parent sends SIGKILL if the child hasn’t exited by then

The 5-minute delta between pana and subprocess timeouts gives the child room to serialise its failure result before the parent force-kills it.

Report caps

CheckLimitBehaviour
Pana report JSON size256 KBReplaced with a placeholder preserving granted points, max points, and original byte count. Prevents one bloated report from ballooning the scores table.

Required external tools

The scoring system fails the server boot if any of these are missing from PATH. All of them ship with Debian’s webp package (already in the runtime Docker image):

  • webpinfo
  • cwebp
  • dwebp
  • gif2webp
  • webpmux

Sandbox Layers

Three optional layers of OS-level hardening compose on top of the subprocess boundary. Each is opt-in via env vars; any combination is valid.

Composition order, outermost → innermost:

command prefix → setpriv → bash -c 'ulimit …; exec' → scoring subprocess

Layer 1 — UID / GID drop

Drops the subprocess to a dedicated unprivileged user via setpriv --reuid --regid --clear-groups. Linux only; silently skipped on macOS (dev).

Env varExampleEffect
SCORING_SANDBOX_UID1001Numeric UID to drop to. Empty = no drop.
SCORING_SANDBOX_GID1001Numeric GID to drop to. Empty = no drop.

Requires CAP_SETUID when the server isn’t running as root. When the server runs as root (the container default), no extra capability is needed.

Layer 2 — Resource limits (ulimit)

Caps applied via a bash -c 'ulimit …; exec "$@"' wrapper. Works everywhere bash does.

Env varFormatExample
SCORING_SANDBOX_RLIMITSComma-separated flag=value pairsv=4194304,t=3600,n=1024

flag is the ulimit short letter (without the leading dash). Supported by bash:

FlagMeaningTypical unit
vVirtual memory / address spaceKB
tCPU timeseconds
nOpen file descriptorscount
uUser processescount
fMax file sizeKB
cCore dump sizeKB

Layer 3 — Command prefix (ops escape hatch)

An arbitrary argv prefix applied outermost. Ops tooling of choice — bwrap, firejail, systemd-run --scope, nsenter, runc exec, runsc, …

Env varExample
SCORING_SANDBOX_PREFIXbwrap --unshare-all --ro-bind / / --dev /dev --proc /proc

The value is split on whitespace to form the argv prefix. Quoting is not respected — if you need spaces inside an argument, either invoke a wrapper script or set the prefix from Docker with careful escaping.


Environment Variables

Env vars that affect validation, scoring, and sandboxing, read once at server bootstrap.

Validation

VariableDefaultPurpose
SERVER_URL(unset)Public URL of this club instance. Automatically added to the hosted-URL allowlist so packages on this instance can depend on each other. When unset, only pub.dev is allowlisted.

Scoring & sandbox

VariableDefaultPurpose
SCORING_SUBPROCESS_BINARY(unset)Absolute path to the AOT-compiled scoring subprocess binary. When unset, the server falls back to running the subprocess via the Dart SDK on PATH — acceptable in dev, too slow for prod cold-starts.
SCORING_SANDBOX_PREFIX(unset)See Layer 3.
SCORING_SANDBOX_UID(unset)See Layer 1.
SCORING_SANDBOX_GID(unset)See Layer 1.
SCORING_SANDBOX_RLIMITS(unset)See Layer 2.

Unset / empty values always mean “layer off”. Empty strings are treated the same as missing.


Docker Image Defaults

The official runtime image ships with all three sandbox layers pre-configured. No extra setup needed at docker run time.

SettingDefault value
SCORING_SUBPROCESS_BINARY/app/scoring_subprocess/bin/scoring_subprocess
SCORING_SANDBOX_UID1001
SCORING_SANDBOX_GID1001
SCORING_SANDBOX_RLIMITSv=4194304,t=3600,n=1024 (4 GB VM, 1 h CPU, 1024 fds)
SCORING_SANDBOX_PREFIX(unset — escape hatch, not enabled by default)

An unprivileged scoring user/group is created in the image at the listed UID/GID. The entrypoint prepares /data/tmp/uploads, /data/cache/pub-cache, and /data/cache/dartdoc with sticky-bit world-writable perms (1777) so both the server and the dropped UID can write to them.

Opting out

To disable a layer for a single container run, pass the env var set to empty:

Terminal window
docker run -e SCORING_SANDBOX_UID= -e SCORING_SANDBOX_GID= club:latest

To override the rlimits:

Terminal window
docker run -e SCORING_SANDBOX_RLIMITS='v=8388608,t=7200,n=2048' club:latest

To layer a custom sandbox on top:

Terminal window
docker run \
--cap-add SYS_ADMIN \
-e SCORING_SANDBOX_PREFIX='bwrap --unshare-all --ro-bind / / --dev /dev --proc /proc' \
club:latest