Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions cmd/cork-build/challenge_commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions cmd/corkd/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
19 changes: 18 additions & 1 deletion cork/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions cork/database_challenges.go
Original file line number Diff line number Diff line change
Expand Up @@ -780,7 +780,8 @@ const (
templatable,
maxusers,
category,
points
points,
difficulty
)
VALUES (
:id,
Expand All @@ -796,7 +797,8 @@ const (
:templatable,
:maxusers,
:category,
:points
:points,
:difficulty
);`

challengeUpdateQuery string = `
Expand All @@ -812,7 +814,8 @@ const (
templatable = :templatable,
maxusers = :maxusers,
category = :category,
points = :points
points = :points,
difficulty = :difficulty
WHERE id = :id;`
)

Expand Down
70 changes: 70 additions & 0 deletions cork/database_operations_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ func TestDatabaseChallengeRoundTrip(t *testing.T) {
MaxUsers: 0,
Category: "Web",
Points: 100,
Difficulty: "hard",
ChallengeOptions: ChallengeOptions{
Overrides: map[string]ContainerOptions{
"": {},
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down
10 changes: 10 additions & 0 deletions cork/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions cork/loader_markdown.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
46 changes: 46 additions & 0 deletions cork/loader_markdown_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions cork/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
2 changes: 1 addition & 1 deletion e2e/scenario.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions examples/specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading