diff --git a/cmd/cork-build/challenge_commands.go b/cmd/cork-build/challenge_commands.go index 7e12d2a..6168c10 100644 --- a/cmd/cork-build/challenge_commands.go +++ b/cmd/cork-build/challenge_commands.go @@ -103,6 +103,9 @@ func infoCommand(mgr *cork.Manager, args []string) int { fmt.Printf(" Challenge Type: %s\n", cMeta.ChallengeType) fmt.Printf(" Category: %s\n", cMeta.Category) fmt.Printf(" Points: %d\n", cMeta.Points) + if cMeta.Difficulty != "" { + fmt.Printf(" Difficulty: %s\n", cMeta.Difficulty) + } if *verbose { fmt.Printf("\n Description:\n %s\n", cMeta.Description) fmt.Printf("\n Details:\n %s\n", cMeta.Details) diff --git a/cmd/corkd/swagger.yaml b/cmd/corkd/swagger.yaml index 353b305..ad0503e 100644 --- a/cmd/corkd/swagger.yaml +++ b/cmd/corkd/swagger.yaml @@ -384,6 +384,13 @@ definitions: points: type: integer format: int32 + difficulty: + type: string + enum: [easy, medium, hard] + description: >- + The difficulty declared in problem.md. Absent when the challenge + declares none, and front-ends should then keep whatever difficulty + they hold for it. tags: type: array items: diff --git a/cork/database.go b/cork/database.go index e1e275c..6cc5ba7 100644 --- a/cork/database.go +++ b/cork/database.go @@ -32,7 +32,8 @@ const schemaQuery string = ` templatable INTEGER NOT NULL CHECK(templatable == 0 OR templatable == 1), maxusers INTEGER NOT NULL CHECK(maxusers >= 0), category TEXT, - points INTEGER NOT NULL CHECK(points >= 0) + points INTEGER NOT NULL CHECK(points >= 0), + difficulty TEXT NOT NULL DEFAULT '' ); CREATE TABLE IF NOT EXISTS hints ( @@ -302,6 +303,22 @@ func (m *Manager) initDatabase() error { } } + // Migrate older DBs: add challenges.difficulty. The empty default is "not + // declared", which every existing challenge was. + var difficultyColumnCount int + err = db.QueryRow("SELECT COUNT(1) FROM pragma_table_info('challenges') WHERE name='difficulty';").Scan(&difficultyColumnCount) + if err != nil { + m.log.errorf("could not inspect challenges schema: %s", err) + return err + } + if difficultyColumnCount == 0 { + _, err = db.Exec("ALTER TABLE challenges ADD COLUMN difficulty TEXT NOT NULL DEFAULT '';") + if err != nil { + m.log.errorf("could not migrate challenges.difficulty column: %s", err) + return err + } + } + // Migrate older DBs: add builds.checksum, the content identity embedded in // each build's docker tags. Rows predating the column are backfilled and // their local images retagged from the legacy id-based tag form so existing diff --git a/cork/database_challenges.go b/cork/database_challenges.go index 25ce421..446fb0a 100644 --- a/cork/database_challenges.go +++ b/cork/database_challenges.go @@ -780,7 +780,8 @@ const ( templatable, maxusers, category, - points + points, + difficulty ) VALUES ( :id, @@ -796,7 +797,8 @@ const ( :templatable, :maxusers, :category, - :points + :points, + :difficulty );` challengeUpdateQuery string = ` @@ -812,7 +814,8 @@ const ( templatable = :templatable, maxusers = :maxusers, category = :category, - points = :points + points = :points, + difficulty = :difficulty WHERE id = :id;` ) diff --git a/cork/database_operations_test.go b/cork/database_operations_test.go index b4a9696..b8152cf 100644 --- a/cork/database_operations_test.go +++ b/cork/database_operations_test.go @@ -35,6 +35,7 @@ func TestDatabaseChallengeRoundTrip(t *testing.T) { MaxUsers: 0, Category: "Web", Points: 100, + Difficulty: "hard", ChallengeOptions: ChallengeOptions{ Overrides: map[string]ContainerOptions{ "": {}, @@ -72,6 +73,9 @@ func TestDatabaseChallengeRoundTrip(t *testing.T) { if got.Points != 100 { t.Errorf("expected Points 100, got %d", got.Points) } + if got.Difficulty != "hard" { + t.Errorf("expected Difficulty 'hard', got %q", got.Difficulty) + } if len(got.Hints) != 2 || got.Hints[0] != "hint1" || got.Hints[1] != "hint2" { t.Errorf("unexpected Hints: %v", got.Hints) } @@ -170,6 +174,7 @@ func TestDatabaseUpdateChallenge(t *testing.T) { challenge.Hints = []string{"new hint 1", "new hint 2", "new hint 3"} challenge.Tags = []string{"updated", "modified"} challenge.Attributes = map[string]string{"version": "2", "status": "active"} + challenge.Difficulty = "easy" errs = mgr.updateChallenges([]*ChallengeMetadata{challenge}, nil, false) if len(errs) > 0 { @@ -196,6 +201,71 @@ func TestDatabaseUpdateChallenge(t *testing.T) { if got.Attributes["status"] != "active" { t.Errorf("expected status=active, got %q", got.Attributes["status"]) } + if got.Difficulty != "easy" { + t.Errorf("expected difficulty set by the update, got %q", got.Difficulty) + } +} + +// TestChallengesDifficultyMigration verifies that a challenges table from +// before difficulty existed gains the column in place, that its rows read +// back as declaring none, and that re-running the migration is a no-op. +func TestChallengesDifficultyMigration(t *testing.T) { + dbFile, err := os.CreateTemp("", "cmgr-difficulty-migrate-*.db") + if err != nil { + t.Fatalf("failed to create temp file: %s", err) + } + dbFile.Close() + t.Cleanup(func() { removeDBFiles(dbFile.Name()) }) + + dsn := dbFile.Name() + "?_fk=false&_journal_mode=WAL&_busy_timeout=50&_synchronous=NORMAL" + legacy, err := sqlx.Open("sqlite3", dsn) + if err != nil { + t.Fatalf("failed to open db: %s", err) + } + schema := ` + CREATE TABLE challenges ( + id TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + namespace TEXT NOT NULL, + challengetype TEXT NOT NULL, + description TEXT NOT NULL, + details TEXT, + sourcechecksum INT NOT NULL, + metadatachecksum INT NOT NULL, + path TEXT NOT NULL, + solvescript INTEGER NOT NULL CHECK(solvescript == 0 OR solvescript == 1), + templatable INTEGER NOT NULL CHECK(templatable == 0 OR templatable == 1), + maxusers INTEGER NOT NULL CHECK(maxusers >= 0), + category TEXT, + points INTEGER NOT NULL CHECK(points >= 0) + ); + INSERT INTO challenges VALUES ('test/old', 'Old', 'test', 'custom', '', '', 1, 1, '/tmp/old', 0, 0, 0, 'Web', 10);` + if _, err := legacy.Exec(schema); err != nil { + t.Fatalf("failed to create legacy schema: %s", err) + } + legacy.Close() + + os.Setenv(DB_ENV, dbFile.Name()) + defer os.Unsetenv(DB_ENV) + + for _, pass := range []string{"migration", "idempotence"} { + mgr := new(Manager) + mgr.log = newLogger(DISABLED) + if err := mgr.initDatabase(); err != nil { + t.Fatalf("initDatabase failed (%s pass): %s", pass, err) + } + + // The same query lookupChallengeMetadata scans with: every column + // must map onto ChallengeMetadata. + md := new(ChallengeMetadata) + if err := mgr.db.Get(md, "SELECT * FROM challenges WHERE id=?", "test/old"); err != nil { + t.Fatalf("failed to read legacy row (%s pass): %s", pass, err) + } + if md.Difficulty != "" || md.Points != 10 { + t.Errorf("expected legacy row with no difficulty and 10 points after %s pass, got %q and %d", pass, md.Difficulty, md.Points) + } + mgr.db.Close() + } } // TestDatabaseBuildLifecycle tests the build database operations diff --git a/cork/loader.go b/cork/loader.go index 4ef5a11..88fdbff 100644 --- a/cork/loader.go +++ b/cork/loader.go @@ -137,6 +137,10 @@ const shortLinkAsRePattern string = `\{\{\s*link_as\(["']` + urlPathPattern + `[ var shortLinkAsRe *regexp.Regexp = regexp.MustCompile(shortLinkAsRePattern) +// difficulties are the values a problem.md may declare as its difficulty; +// ChallengeMetadata.Difficulty is empty when it declares none. +var difficulties = []string{"easy", "medium", "hard"} + func (m *Manager) validateMetadata(md *ChallengeMetadata) error { var lastErr error // Require a challenge name @@ -145,6 +149,12 @@ func (m *Manager) validateMetadata(md *ChallengeMetadata) error { m.log.error(lastErr) } + if md.Difficulty != "" && !slices.Contains(difficulties, md.Difficulty) { + lastErr = fmt.Errorf("invalid difficulty '%s' (must be one of %s): %s", + md.Difficulty, strings.Join(difficulties, ", "), md.Path) + m.log.error(lastErr) + } + // Validate Namespace re := regexp.MustCompile(`^([a-z0-9]+(/[a-z0-9]+)*)?$`) if !re.MatchString(md.Namespace) { diff --git a/cork/loader_markdown.go b/cork/loader_markdown.go index e235741..1eafbbd 100644 --- a/cork/loader_markdown.go +++ b/cork/loader_markdown.go @@ -153,6 +153,8 @@ func (m *Manager) loadMarkdownChallenge(path string, info os.FileInfo) (*Challen i, tmpErr := strconv.Atoi(match[2]) md.MaxUsers = i err = tmpErr + case "difficulty": + md.Difficulty = strings.ToLower(match[2]) default: err = fmt.Errorf("unrecognized top-level attribute '%s' on line %d: %s", match[1], idx, path) m.log.error(err) diff --git a/cork/loader_markdown_test.go b/cork/loader_markdown_test.go index b09cc7f..a1cbfff 100644 --- a/cork/loader_markdown_test.go +++ b/cork/loader_markdown_test.go @@ -619,6 +619,52 @@ func TestTagLineRegex(t *testing.T) { } } +// TestDifficulty verifies that a declared difficulty is read in any case and +// stored lower-cased, and that validation admits only the three levels or +// none at all. +func TestDifficulty(t *testing.T) { + mgr := newTestManager() + + content := "# Difficulty Challenge\n\n- Type: custom\n- Difficulty: Hard\n" + dir := t.TempDir() + path := filepath.Join(dir, "problem.md") + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("failed to write problem.md: %s", err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatalf("failed to stat problem.md: %s", err) + } + md, err := mgr.loadMarkdownChallenge(path, info) + if err != nil { + t.Fatalf("loadMarkdownChallenge failed: %s", err) + } + if md.Difficulty != "hard" { + t.Errorf("expected difficulty 'hard', got %q", md.Difficulty) + } + + for _, tc := range []struct { + difficulty string + valid bool + }{ + {"", true}, + {"easy", true}, + {"medium", true}, + {"hard", true}, + {"insane", false}, + {"1", false}, + } { + md := &ChallengeMetadata{Name: "Difficulty Challenge", Difficulty: tc.difficulty} + err := mgr.validateMetadata(md) + if tc.valid && err != nil { + t.Errorf("difficulty %q: expected valid, got %s", tc.difficulty, err) + } + if !tc.valid && err == nil { + t.Errorf("difficulty %q: expected an error, got nil", tc.difficulty) + } + } +} + // TestLoadMarkdownChallengeFailsOnSectionError verifies that an error while // processing a markdown section — here a duplicate mapping key, which yaml v3 // rejects — fails the whole load rather than being logged and swallowed. A diff --git a/cork/types.go b/cork/types.go index 2c8432c..5e45772 100644 --- a/cork/types.go +++ b/cork/types.go @@ -221,6 +221,7 @@ type ChallengeMetadata struct { MaxUsers int `json:"max_users,omitempty"` Category string `json:"category,omitempty"` Points int `json:"points,omitempty"` + Difficulty string `json:"difficulty,omitempty"` Tags []string `json:"tags,omitempty"` Attributes map[string]string `json:"attributes,omitempty"` ChallengeOptions ChallengeOptions `json:"challenge_options,omitempty"` diff --git a/e2e/scenario.sh b/e2e/scenario.sh index 0361c8e..e38e9f6 100755 --- a/e2e/scenario.sh +++ b/e2e/scenario.sh @@ -2858,7 +2858,7 @@ if (( FULL )); then step "container options: a challenge that asks for cpu, memory, pid and file limits gets them in its container's HostConfig on the worker" # Production runs the whole picoCTF library, and that library hardens its # challenges through the "## Challenge Options" block - # (examples/specification.md:93-190, examples/multi/problem.md:41-52): the + # (examples/specification.md:100-197, examples/multi/problem.md:41-52): the # cpu, memory and pid ceilings are what keep one instance from taking a # worker down with it. Nothing in e2e/schema.yaml declares any -- its four # challenges are chosen by delivery type, not by hardening -- and this step diff --git a/examples/specification.md b/examples/specification.md index b2e163e..58191a2 100644 --- a/examples/specification.md +++ b/examples/specification.md @@ -4,6 +4,13 @@ - Type: custom - Category: example - Points: 1 +- Difficulty: easy + +## Metadata + +`Difficulty` is optional and is one of `easy`, `medium` or `hard`, +case-insensitive; any other value fails the load. A challenge that leaves it out reports no +difficulty, and front-ends keep whatever they already hold for it. ## Body Content