diff --git a/.claude/skills/add-level/SKILL.md b/.claude/skills/add-level/SKILL.md new file mode 100644 index 0000000..d3cb045 --- /dev/null +++ b/.claude/skills/add-level/SKILL.md @@ -0,0 +1,108 @@ +--- +name: add-level +description: Write, change, move or remove a ShellGame level or section (a `Level { .. ..LEVEL }` struct literal in src/levels/sNN_
.rs) and verify it with the course validation and the bash and fish replay. Use when asked to add a level, fix a level's text, hints, answer, checks, cd policy or solution, reorder levels, or add a section. +--- + +# Add or change a level + +The format and the rules (positions, Czech text, hints, no leaked answers, solutions, vocabulary) are in +`AGENTS.md`, section "Levels". Read it first; this skill is the procedure. + +## 1. Find the place and the position + +- Sections are `src/levels/sNN_.rs`, numbered by position (`s00_welcome.rs` … `s11_search.rs`) and + listed in that order in `COURSE` in `src/levels/mod.rs`; moving a section means renaming its file. Section S works in `/level-S`. +- Read the section's goals block (the `//!` comment at the top of its file) before touching a level. The + level must serve one of its learning objectives and must not ask the player for anything under *Leaves + for later*. A change that widens or narrows what the section teaches updates the block in the same change + (new commands go under *Introduces*, a borrowed later tool under *Relies on*). +- Read the levels around the spot and copy their tone, difficulty and use of the vocabulary. Address the + player in the formal plural like the existing levels ("Přejděte", "Vytvořte"). +- Appending to a section's `levels` list shifts nothing. Inserting, moving or removing a level (or a + section) shifts the later positions "S.L" and therefore later saved games. That is accepted; say so in + your report and in the commit message. + +## 2. Decide what makes the level "solved" + +- A discovery the player reports (a name, a content, a count, a size): `answer:` with `exact`, `one_of`, + `int`, `suffix` or `parts`. A missing answer gets the default "Musíte zadat odpověď"; add + `.format("...")` only to show the shape of a `parts` answer or how to submit. Judging drops trailing + slashes (Tab completion adds them), so an answer never ends in one. +- Something the player must do to files or location: `check: &[..]` (`at`, `is_file`, `no_file`, + `content_is`, `moved`, `mode_is`, ...). Checks run before the answer, so a correct answer never + bypasses them. When the files are the real answer, the level has no answer: the text and the solution + say a bare `shellgame submit`, never a label. +- Both, when the player must do something and report what they found. +- `cd: &[..]` only for a level that practises one exact movement command; then add `cd_done()` to `check`. + The engine keeps a strict level from trapping the player (moving back to the start directory is always + allowed) and a course test checks that every `cd` list blocks something. + +## 3. Write the literal + +Copy the shape from AGENTS.md or a neighbour. Fields: `title`, `text`, `hints`, `start`, `setup`, +`answer`, `check`, `cd`, `solution`, `success`, then `..LEVEL` (a literal that sets every field leaves it +out: clippy rejects a needless one). + +- `text` and `solution` are indented raw strings: content starts on the line after `r#"`, spaces only. +- Paths, `start` included, are relative to the section directory (`level-S`); a level starts there unless + `start` names a directory below it. Everything the level needs is in its own `setup`: no shared files. +- `setup`: everything the level needs; `clean` what the player may create; `.mode(..)` for anything whose + permissions the level grades. It must survive `chmod 000` and similar, because `reset` re-applies it. + Multi-line file contents are indented blocks like `text`; `file` adds the final newline. +- Text: goal, numbered steps, how to submit. Commands in backticks, navigation relative to the start + directory, no user names. A level that teaches bash-only syntax says so, and offers fish players + `bash -c '...'`. +- Hints (plain text, printed literally): the idea, then the shape of the command, then the command. + Hint 1 is never the whole solution; for discovery tasks hints explain how to find the answer. +- Replies (`.error`, `.mistakes`, `.format`, check messages): short, teach without revealing. Never + contain the answer, and a failed `at(..)` must not name the target directory. +- `success`: say what the command did, not praise. +- Custom logic (maze, generated files, stateful `cd` drills) is a plain `fn` at the bottom of the same + section file, referenced from the literal. + +## 4. Write the solution + +What a student would type, one command per line, from the start directory, ending in +`shellgame submit [answer]`. Derive the answer with the commands the level teaches. Keep it valid in bash +and fish (`"$(...)"`, `"$PWD"`; no `[[ ]]`, `$'..'`, `local`, `export X=1`), non-interactive, and +independent of umask (`chmod u+x`, not numbers). Bash-only syntax that the level teaches goes through +`bash -c '...'`, so the solution still runs in fish. + +## 5. Verify + +```bash +cargo fmt +cargo test # course validation over every level, plus all other suites +cargo test --test e2e -- solutions::3.2:: # replay one level (here 3.2) in bash and fish, fresh and re-prepared +cargo clippy --all-targets -- -D warnings +RUSTDOCFLAGS="-D warnings" cargo doc --no-deps # the goals blocks and their links between sections +``` + +If a validation test fails, it is usually right. Typical causes: an answer inside an error, mistake or +format text; an answer that ends in `/`; a solution that does not end in `shellgame submit`; a level +with neither answer nor check; tabs or text on the first line of an indented raw string; an absolute +or `..` path; a `level-N` or `Sekce N` mention that is not its own section's number (another section is +named by its title). + +## 6. After moving, inserting or removing a level or section + +Positions shift, so re-read the texts that mention them: + +```bash +grep -nE 'level-[0-9]+|Sekc[ei] [0-9]+' src/levels/*.rs +``` + +Every hit, in texts, hints, answers (an answer can be `level-1`) and solutions, must match the position +of its own section; a test enforces this for texts. Another section is named by its title, never by +number, and a level in a comment likewise. Do not use blind search-and-replace: these literals are +shared. A new section is a new file `sNN_.rs` with a `pub const SECTION: Section` (all four +fields), declared and listed at its position in `src/levels/mod.rs`. Its `intro` follows the neighbours: +the concept, a mental model, the commands introduced, pages split by `---`, and no heading at the top +(the panel title shows "Sekce N: "). Every later section moves one position down: rename its +file, and its `level-N` directory changes too. A new section file starts with its goals block (copy a +neighbour's shape, link other sections as `[navigation](super::s01_navigation)`); after a rename or a +move fix those links and the `pub mod` lines, and let `cargo doc` (step 5) flag every stale one. + +## 7. Look at it + +Play the level the way a student does with the `playtest` skill; `shellgame resume S.L` takes you to it. diff --git a/.claude/skills/playtest/SKILL.md b/.claude/skills/playtest/SKILL.md new file mode 100644 index 0000000..870c046 --- /dev/null +++ b/.claude/skills/playtest/SKILL.md @@ -0,0 +1,99 @@ +--- +name: playtest +description: Play ShellGame by hand in a throwaway environment (fake HOME, state directory and workspace) in bash and fish, checking intro paging, hints, wrong answers, strict cd levels, resume, levels, reset and the sandbox. Use after changing levels, the UI, the shell scripts, the sandbox or the CLI, before a release, or to see a level the way a student does. +--- + +# Playtest in a throwaway environment + +Never play against your own save or your real `$HOME`: the checks below change and delete files on +purpose. The snippets are bash. Run them from the repository root in an ordinary terminal, not inside a +game shell. + +## 1. Build and set up + +```bash +cargo build +BIN="$PWD/target/debug/shellgame" +T=$(mktemp -d /var/tmp/shellgame-playtest.XXXXXX) # outside /tmp: the sandbox must have something to protect +mkdir -p "$T/home" "$T/xdg" +echo precious > "$T/home/precious.txt" +W=/tmp/shellgame-playtest-$$ # the workspace must be inside /tmp +export T W BIN +run() { env HOME="$T/home" XDG_CONFIG_HOME="$T/xdg" SHELLGAME_WORKSPACE="$W" "$BIN" "$@"; } +``` + +Do not use `cargo run` with a changed `HOME`: cargo and rustup live there. Progress goes to +`$T/xdg/shellgame/state.json`, level files to `$W`; confirm both after the first screen. +Do not put `$T` under `/tmp`: everything there is writable inside the sandbox, so it would prove nothing. + +## 2. Start the game, once per shell + +```bash +run --shell bash # then run --shell fish (keep the state, or start fresh: see step 4) +``` + +The game needs a terminal. Without one, drive it through tmux on a private socket: + +```bash +tmux -L sgplay new-session -d -s sg -x 110 -y 40 \ + "env HOME=$T/home XDG_CONFIG_HOME=$T/xdg SHELLGAME_WORKSPACE=$W $BIN --shell bash" +tmux -L sgplay send-keys -t sg 'shellgame hint' Enter +tmux -L sgplay capture-pane -t sg -p +tmux -L sgplay kill-server +``` + +Subcommands also run outside the game shell and without a terminal: `run resume 3.2; run status`. + +## 3. What to try, in both shells + +- **Start.** Intro pages advance with Enter; under each level panel is the isolation line ("Izolace + zapnuta: ..."). Ctrl-C at an Enter prompt must leave the shell usable. +- **Hints.** `shellgame hint` three times: the hints escalate, and every call shows the unlocked ones + again with one more, until it says there are none left. +- **Answers.** `shellgame submit nonsense` on a level that takes an answer: a short reply that does not + reveal the answer. A bare `shellgame submit`: a reply that shows the example command. +- **Strict cd.** In a level that practises one exact `cd` (section 1), a wrong `cd` is blocked with a red + panel and the `shellgame reset` tip; `cd` back to the start directory works; `shellgame reset` puts you + in the start directory. +- **Resume.** From fresh state (see step 4): `shellgame resume 3.2` typed in an ordinary shell starts the + game shell in level 3.2 (also with `--shell fish` and for `reset` and `submit`); inside, `shellgame status` + and `shellgame exit` print "Na jiném počítači pokračujte příkazem: shellgame resume 3.2", `shellgame resume 3` + goes to the first level of section 3 after its intro, `resume` alone and `resume 99` fail (status 1) and + point to `shellgame levels`, which lists every level with "▸ zde" and "✓" (also outside the game shell, + where `exit` says you are not in it). Resuming a completed level keeps it counted in `status`. +- **Show.** `shellgame show` (the current assignment), `show 1.2` (another one) and `show 1` (the intro + of a section) print without touching the game; `show 99` and `show x` fail with a message and status 1. +- **Deleted directory.** In a level that is judged on where you stand (`shellgame resume 1.3`) run + `mkdir x && cd x && rmdir "$PWD"` (fish: `mkdir x; and cd x; and rmdir "$PWD"`), then + `shellgame submit`: it says the directory is gone, and `shellgame reset` puts you back at the start. +- **Reset after `chmod 000`.** `shellgame resume 8`, run `chmod 000` on the level's files and directories + in the workspace, then `shellgame reset`: the level is rebuilt and you are back at its start. +- **Sandbox (Linux).** Inside the game shell each of these must fail and leave the file intact: + `rm "$HOME/precious.txt"`, `echo x > "$HOME/precious.txt"`, `touch "$HOME/new"`, + `mv "$HOME/precious.txt" "$HOME/moved"`, `mkdir "$HOME/d"`, `chmod 777 "$HOME/precious.txt"` (the typed + `chmod` is refused with a Czech message, the others with "Permission denied"). These must work: files + in the workspace and in `/tmp`, and `shellgame submit`. `sudo true` failing is expected, and + `shellgame update` only tells you to run it outside. Known limits, not bugs: `command chmod`, + `/usr/bin/chmod`, `find -exec chmod` and `touch` on an existing file still work. Leave the game shell + and check that `cat "$T/home/precious.txt"` still prints `precious` and `ls -A "$T/home"` lists only + that file. +- **Opt-out.** `run --unsafe --shell bash` starts with a red warning and no isolation. Run destructive + commands there only against files under `$T`. +- **Without Landlock** (a VM or container with an old kernel) the game must refuse to start and explain + why; `--unsafe` must still start it. + +Note which shell and which level each problem happened in. + +## 4. Start fresh, and clean up + +Section 8 leaves mode-000 files and directories, so restore permissions before deleting: + +```bash +# a fresh game with the same fake home +chmod -R u+rwx "$T/xdg" "$W" 2>/dev/null; rm -rf "$T/xdg" "$W"; mkdir "$T/xdg" +# when you are done +chmod -R u+rwx "$T" "$W" 2>/dev/null; rm -rf "$T" "$W" +``` + +Remove only what you created here, and kill the tmux server if you started one +(`tmux -L sgplay kill-server`). diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md new file mode 100644 index 0000000..4993b5c --- /dev/null +++ b/.claude/skills/release/SKILL.md @@ -0,0 +1,84 @@ +--- +name: release +description: Build and publish ShellGame. Explains how CI turns a push to main into a build-N GitHub release, the dry run with workflow_dispatch, the PUBLISH_MACOS hold-back, and how scripts/install.sh and `shellgame update` pick up releases. Use when asked to release or publish, to test the release pipeline, to enable macOS assets, or to change .github/workflows/ci.yml or scripts/install.sh. +--- + +# Release + +A push to `main` publishes to students: `scripts/install.sh` and `shellgame update` serve the latest +release. **Never push, merge or tag unless the maintainer asked for exactly that.** A dry run needs the +branch on the remote, so it needs their go-ahead too. + +## How CI works (`.github/workflows/ci.yml`) + +| Job | Runs on | Does | +| --- | --- | --- | +| `test` | matrix: ubuntu-24.04 and ubuntu-22.04 | `cargo test --locked` with `SHELLGAME_TEST_LANDLOCK=required` on both legs; 22.04 has the oldest supported fish (3.3) and bash (5.1). Only the 24.04 leg also runs shellcheck of every tracked `*.sh`, `cargo fmt --check`, `cargo doc` (warnings denied) and clippy `-D warnings` | +| `test-macos` | macos-latest | system bash 3.2 first on `PATH`; `cargo test --locked --test shell` blocks (bash 3.2 parity of the integration script), the full `cargo test --locked` is non-blocking | +| `build` | after `test` (both legs) and `test-macos` | on a push to the default branch and on `workflow_dispatch`: one archive per target, each holding exactly one file, `shellgame`; `SHELLGAME_BUILD=build-<run number>` is compiled in and shown by `--version`; the `Smoke test` step runs the built binary and checks that (x86_64 macOS runs under Rosetta, and only prints a notice when it is missing); on Linux, `Smoke test the game shell` checks that the binary is static and that it starts a confined game shell that runs a typed command, saves state and cannot write to the home | +| `publish` | after `build` | push to the default branch only, one run at a time: a run whose commit is no longer the tip of the default branch (overtaken, or started again later) publishes nothing; otherwise GitHub release `build-<run number>` with the archives, marked latest | + +Assets are `shellgame-{linux,macos}-{x86_64,aarch64}.tar.gz` (static musl on Linux). `scripts/install.sh` +and `shellgame update` download `releases/latest/download/<asset>`, so the names are an interface: do +not rename them. The publish job tags the built commit itself; do not create tags or releases by hand. +The crate version in `Cargo.toml` is not bumped per release. + +## Dry run (builds everything, publishes nothing) + +```bash +BRANCH=$(git branch --show-current) +gh workflow run ci.yml --ref "$BRANCH" # the workflow file must exist on the default branch +RUN=$(gh run list -w ci.yml -b "$BRANCH" -e workflow_dispatch -L 1 --json databaseId --jq '.[0].databaseId') +gh run watch "$RUN" --exit-status +D=$(mktemp -d); gh run download "$RUN" -n shellgame-linux-x86_64.tar.gz -D "$D" +tar -tzf "$D/shellgame-linux-x86_64.tar.gz" # must list only: shellgame +``` + +Run `gh workflow run` only after the maintainer pushed the branch. Archives of a dry run are workflow +artifacts; the `publish` job is skipped. In the run, `test` shows two legs, `test (ubuntu-24.04)` and +`test (ubuntu-22.04)` (the 22.04 leg skips the shellcheck, formatting, docs and clippy steps), and the +`Smoke test` step of every `build` leg prints `shellgame <version> (build-<run number>)` (the x86_64 +macOS leg may print a no-Rosetta notice instead). + +## macOS assets: `PUBLISH_MACOS` + +The macOS legs always build (non-blocking), but `publish` deletes `shellgame-macos-*` while the +`PUBLISH_MACOS` variable at the top of `ci.yml` is `"false"`. Flip it to `"true"` only after the macOS +content review (GNU-only spots in the levels: `ls --help`, `wc` padding, `file` wording, bracket globs +under locale collation) has made the macOS replay green. Do these together: make the full macOS `Test` +step blocking (drop `continue-on-error` there and on the macOS `build` legs), flip the variable, and +update the macOS sentence in `README.md`. + +## Before a release + +1. `cargo fmt --check && cargo clippy --all-targets --locked -- -D warnings`, then + `SHELLGAME_TEST_LANDLOCK=required cargo test --locked`. +2. Playtest in bash and fish (the `playtest` skill): resume, strict cd, reset, the sandbox. +3. Hosted runners have recent kernels, so old Landlock ABIs and kernels without Landlock (Debian 11, + RHEL 8 and 9.0 to 9.5, Ubuntu 20.04 with its original kernel, WSL 1) are checked by hand: the game + must isolate as far as the kernel allows, or refuse and explain, and `--unsafe` must start it. +4. Ask the maintainer to push (a branch for a dry run, `main` for the release). + +## After the publish run + +```bash +gh release view # the newest release, build-<N>: the Linux archives are attached +curl -fsSL https://raw.githubusercontent.com/jdupak/ShellGame/main/scripts/install.sh | SHELLGAME_INSTALL_DIR="$(mktemp -d)" sh +``` + +The script prints where it installed `shellgame`; `<that path> --version` must report `build-<N>`. +From an older install, `shellgame update` (run outside the game shell) must replace the binary and keep +the save. + +## Changing the pipeline or the installer + +Keep `scripts/install.sh` POSIX `sh` (students pipe it into `sh` or `bash`; it must also run in dash +and bash 3.2), with its body in functions called from the last line, and clean under `shellcheck`. The `build` job installs +no `musl-tools`: no dependency compiles C (`Cargo.lock` has no `cc` crate), so rustc's bundled musl is +enough; a crate with a C build script would need `musl-tools` in the Linux legs again. Validate edits +locally and prove them with a dry run: + +```bash +git ls-files -z '*.sh' | xargs -0 shellcheck # the "Lint shell scripts" step: every tracked script +uv run --no-project --with pyyaml python -c 'import yaml; yaml.safe_load(open(".github/workflows/ci.yml"))' +``` diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index d8a7448..0000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,453 +0,0 @@ -# Copilot Instructions — ShellGame - -You are working in **ShellGame**, an interactive terminal learning game written in **Python** using **Click** (CLI) and **Rich** (UI). The game runs inside a wrapped **subshell** (bash or fish) to control navigation, observe commands, and persist progress. - -Your job is to make changes that preserve existing UX and gameplay rules. Prefer small, targeted edits and keep user-facing text concise (Czech). - -**Before making shell integration changes, read this entire section carefully.** - ---- - -## 1) Tech stack (what to assume) - -- Language: **Python 3.10+** -- Dependency Management: **uv** (use `uv sync`, `uv add`, etc.) -- Task Runner: **make** (use `make test`, `make lint`, etc.) -- CLI: **Click** -- UI: **Rich** panels + consistent helper notes -- Tests: **pytest** (run via `make test`) -- Shell integration: wrapped subshell (**bash**/**fish**) — see section 3.2 for critical details -- Persistence: state saved/loaded between sessions - ---- - -## 2) Where code lives (edit the right place) - -### Common responsibilities - -- CLI flow, teleport: - - `src/shellgame/cli/commands.py` - -- Subshell launch, shell detection: - - `src/shellgame/cli/subshell.py` - -- Shell integration templates (bash/fish): - - `src/shellgame/cli/templates/bash_integration.template` - - `src/shellgame/cli/templates/fish_integration.template` - -- UI panels, wording consistency, indentation, paging: - - `src/shellgame/ui/display.py` - -- Level setup + validation rules + shell hooks (OOP): - - `src/shellgame/levels/sections/section*.py` - - Logic specific to a level (hooks, validation) must live in the `Level` class, not global handlers. - -- Declarative level infrastructure: - - `src/shellgame/levels/collector.py` — section registration and stable local IDs - - `src/shellgame/levels/fixture.py` — repeatable workspace setup/cleanup - - `src/shellgame/levels/completion.py` — answers and unconditional requirements - - `src/shellgame/levels/cdpolicy.py` — declarative `cd` restrictions - - `src/shellgame/levels/solution.py` — reference solutions that prove a level is solvable - - `src/shellgame/paths.py` — the single path-containment implementation - -- Authoring guide (read before adding a level or section): - - `docs/AUTHORING.md` - -- Section intro markdown (content only; supports paging via `---`): - - `src/shellgame/levels/content/section*_intro.md` - -- Persistent progress model/load/save: - - `src/shellgame/state/manager.py` - - Missing state may initialize a game; corrupt state must raise an error and must not be overwritten. - - State-changing actions should perform one atomic save. - -### Level IDs -- Declare the persistent section number with `Section(number, ...)`. -- Declare each stable level suffix locally with `@section.level(number)`. -- Never derive IDs from declaration order; IDs are persisted in player state. -- Section intros end with `.0` (e.g., `1.0`, `2.0`) and support paging. -- Regular levels are `X.Y`. -- Sections have no `get_levels()` trailer; the loader reads the module-level - `section` object. Do not reintroduce one. - ---- - -## 3) Architectural Principles - -### 3.1 OOP over Special Casing -- **Encapsulate logic in Level classes**: Do not hardcode level IDs in `session.py` or `commands.py`. -- **Use overrides**: If a level needs special behavior (e.g., custom validation, hooks), override the corresponding method in its class. -- **Declarative configuration**: Regular levels should combine - `WorkspaceFixture`, `Completion`, typed answer rules, and typed requirements. - Override `setup()` or `validate()` only for genuinely custom behavior. -- **Unconditional safeguards**: Put filesystem, cwd, permissions, and evidence - checks in `Completion.requirements`. Correct or empty answers must never - bypass these checks. -- **Exact completion locations**: Navigation levels must use `AtDirectory` - with a section-root-relative path. Never validate only `Path.cwd().name`, - because a same-named directory outside the workspace must not satisfy the - level. -- **One path vocabulary**: every path a level declares — `start_directory`, - fixtures, requirements, solution steps — is relative to that level's section - root. `""` is the section root; use the `WORKSPACE_ROOT` sentinel for the - rare level that starts above its own section. Never restate the section - directory name inside a level, and never build a path with `state.workspace / - ...` in a section module. -- **Fresh evidence**: Runtime level activation goes through `Level.prepare()`, - which clears declarative evidence, applies the section fixture, applies the - level fixture, and then runs custom setup. Do not call `setup()` directly - from session orchestration. -- **Safe fixtures**: Fixture paths are relative to the section root. Absolute - paths, `..`, cleanup of the fixture root, and symlink escapes are rejected. - All containment goes through `paths.resolve_within()`; do not add a second - implementation. Declare exact directory modes with `DirectoryFixture` in - `WorkspaceFixture.directory_fixtures`; modes are applied after child files so - parent permission recovery cannot overwrite the requested final mode. -- **Declarative movement rules**: strict navigation levels declare a - `cd_policy` (`levels/cdpolicy.py`) instead of writing `_handle_cd`. The - engine then applies the anti-soft-lock contract structurally. Write a custom - `_handle_cd` only for stateful grading that a policy cannot express (level - 1.9 is the sole example). -- **Level-local evidence**: a `cd` policy derives its marker from the level ID - (`cd_marker("1.4")` → `cd-1.4`). Do not add global marker constants and do - not invent ad-hoc dotfile names. -- **Prove solvability**: a level with custom `validate()`/`setup()`, a - `cd_policy`, evidence, or a filesystem requirement must declare a `Solution` - (`levels/solution.py`). It is replayed against a throwaway workspace, both - fresh and after `reset()`. - -### 3.2 Thin Shell Integration -- **Minimal templates**: Keep bash/fish templates as small as possible. -- **Delegate to Python**: Shell scripts should only capture events (like `cd`) and forward them to Python via hooks or protocol directives. -- **No game logic in shell**: Do not implement validation or state management in shell scripts. - ---- - -## 4) Hard UX rules (do not break) - -### 4.1 Teleport notices -ShellGame may auto-`cd` the user to a level start directory. - -Rules: -- Show a teleport notice **only if cwd actually changed**. -- Notice formatting: - - message in **yellow** - - destination path in **violet** - - **no** “from -> to” - - **no** “reason” text -- Helper must remain: `_teleport_notice(destination: Path)` in `commands.py` -- Do not change the signature; call sites must not pass `reason=...`. - -### 4.2 Subshell startup (CRITICAL — read carefully) - -Both bash and fish use a **single integration template** that includes: -1. Shell setup (disable interfering features) -2. Function definitions (`shellgame`, `pwd`, `cd` hooks, `__shellgame_eval`) -3. Autostart block **at the end** (calls `shellgame` function after it's defined) - -#### Bash launch flags -```bash -bash --rcfile <integration_script> -i -``` - -**CRITICAL**: Do NOT use `--norc` — it disables `--rcfile` entirely! -**CRITICAL**: Do NOT use `--noprofile` — let users keep their PATH/env setup. - -The integration script is used directly as the rcfile. There is NO separate rc template. - -#### Fish launch -```bash -fish --init-command "function fish_greeting; end; source <integration_script>" -``` - -Fish greeting is suppressed inline. Autostart happens inside the integration template. - -#### Why autostart is at the end of the template -The `shellgame` **function** (defined in the template) captures stderr and processes protocol directives (`__SHELLGAME_EXEC__...`). If autostart called the external binary directly (before the function is defined), protocol directives would leak to the terminal. - -#### Protocol directives -Python emits versioned operations to stderr using: -`__SHELLGAME_EXEC__v1 <verb> [base64-argument...]`. -The shell wrapper function: -1. Captures stderr to a temp file -2. Decodes and dispatches only supported verbs (`cd`, `export`, `echo`, `pwd`, `exit`) -3. Echoes other stderr lines normally - -Do not add a generic `eval` fallback. Protocol arguments must remain encoded -until the shell-specific verb handler decodes them. - -#### `remove` command special handling -The `shellgame remove` command deletes the workspace (which may be the user's cwd). To avoid `getcwd` errors and leaked protocol lines, the **shell wrapper detects `remove` success and exits the subshell directly** — we do NOT rely on a protocol directive for this. - -### 4.3 One unified “Press Enter to continue” -- Exact text: `Stiskněte Enter pro pokračování...` -- Use UI helper: `Display.wait_for_continue()` -- Do not reintroduce custom prompts in CLI code. - -### 4.4 Section intro paging (`.0` levels) -- Intro markdown pages are split by a line containing only `---` -- Show pages in the standard instruction flow -- Between pages call `Display.wait_for_continue()` -- After the last page the engine waits once more, then auto-submits. Intros - continue with **Enter**, never `shellgame submit`. -- Intro copy and intro hints must say Enter. `shellgame submit` is only for - real levels. - -### 4.5 Hints: progressive + repeat semantics -- `shellgame hint` reveals the **next** hint and increments hint counter. -- `shellgame hint --repeat` reprints already revealed hints **without consuming new ones**. - -Formatting + footers: -- Under hint panels, helper text must use `Display.note(...)` (indent handled programmatically). -- Do not bake leading spaces into helper strings. -- When using `--repeat`: - - do **not** print per-hint helper footers repeatedly - - print the repeat tip **once at the end** - - only show “Potřebujete další pomoc? …” if another hint still exists -- Render level-authored hint text literally. Rich markup must not consume shell - syntax such as `[ab]` or `[a-z]`, in either normal or repeated hints. - -No-more-hints UX: -- Show a **yellow framed** panel containing: - - `Pro tento level již nejsou k dispozici žádné další nápovědy.` -- Under it, show a *note* with the tip containing the command in violet: - - `[violet]shellgame hint --repeat[/violet]` -- Do **not** show “To jsou všechny nápovědy…” in this flow. - -Zero-revealed and zero-available are **different** states: -- `--repeat` with nothing revealed yet must call `Display.show_no_hints_revealed()`. - It must **never** print `hints[0]`, which would be a free hint and would - desynchronise the hint counter shown by `shellgame status`. -- A level with no hints at all uses `Display.show_no_hints_available()`. - -### 4.6 Success/failure panels -- Success: **green framed panel**, centered text (`Display.show_success`) -- Failure: **red framed panel**, centered text (`Display.show_failure`) -- Prefer panels for major state changes rather than raw console lines. -- `Display.show_success` must be given the level's own message plus - `time_sec` / `hints_used` / `attempts`. Never substitute a generic - “Správně!” for a level's `success_message`. -- `attempts` counts **failed** attempts, so it is labelled `Chybné pokusy`. - -### 4.7 Skipping bonus levels -- `Level.is_bonus` is `optional or extension`. -- `shellgame skip` advances only on bonus levels; core levels must show - `Display.show_not_skippable(...)`. -- Skipping is not a completion: it must not write to `levels_complete` and - must not show a success panel. -- Instructions for bonus levels advertise `shellgame skip` via `Display.note`. - -### 4.8 Terminal state -- Completing the last level sets `GameState.completed_at`. -- `submit`, `skip`, `hint`, and `show_current_level` must then be inert and - only re-show `Display.show_game_complete(state)`. - ---- - -## 5) Level validation rules (gameplay correctness) - -### 5.1 “Empty submit” policy (do not workaround in CLI) -Some levels require an explicit answer argument; `shellgame submit` must not silently succeed. - -Enforced expectations: -- Level **1.1** requires `shellgame submit level-1` (empty submit is invalid) -- Level **1.4** requires explicit answer -- Level **1.5** requires explicit answer -- Level **1.3** requires user to be in directory `alpha` when submitting - -If you need to change this behavior, configure the level's `Completion`; use a -custom `validate()` only when the result cannot be expressed declaratively. - -### 5.2 Never leak `None` to the player -If a level expects an answer and `answer is None`, configure the answer rule's -`required_message` with a dedicated, friendly message such as: -- “Musíte zadat odpověď…” plus an example command. - -Do not generate user-facing strings that include `None` (e.g., `"'None' ..."`)—special-case it. -(Level **1.2** already demonstrates this pattern.) - -### 5.3 Never soft-lock the player -Strict `cd` levels only accept one command shape. That must never become a -dead end. - -- **Prefer `cd_policy`.** A declared policy applies everything below - structurally, so a new level cannot forget it. The rules below are the - contract a custom `_handle_cd` must honour by hand. -- Reject a move only through `block_cd(self.id, message)`, which always adds - the `shellgame reset` recovery tip. -- Before rejecting, call - `self.cd_enforcement_lifted(<marker>, target=target, pwd=pwd, state=state)`. - It lifts enforcement once the evidence marker exists (the drill is done) or - when the move returns the player to the level start directory. -- `shellgame reset` re-exports shell context and re-teleports the player. - Do not remove `ensure_user_in_reasonable_place()` from `GameSession.reset()`. -- Lifting *movement* restrictions is safe because `Completion.requirements` - still gate submission. -- `tests/test_cd_hooks_no_softlock.py` enforces all of the above and fails if a - new blocking hook is added without coverage. - -### 5.4 Never reveal the answer in an error -A wrong submission must not print the expected value. Answer rules take -`error_message: str | None = None`; leaving it `None` yields the terse -`Messages.INCORRECT`. Only write an explicit `error_message` when it teaches -without giving the answer away. -`tests/test_level_invariants.py` enforces the default path. -- This also applies to `mistakes` feedback and failed navigation requirements. - `AtDirectory` must not name the expected directory in its default error. -- Discovery hints teach how to find the answer instead of printing it; retain - command examples for explicitly assigned filesystem tasks. - -### 5.5 “Consistency is maintained by the game” -Do not rely on “where the user ended last time”. -If a level needs a specific start directory, enforce it via: -- `start_directory` attribute in the level class, and/or -- a declarative fixture - -Avoid instructions that tell the user to manually correct state the game can guarantee. -- Write navigation commands relative to the declared start directory, and never - hard-code a workspace username. For `cd -` tasks, ensure the instructed moves - establish the intended previous directory. -- When a command prints a path as the answer, state its reference directory or - accept equivalent output after reasonable navigation. Path suffix matching - must respect `/` component boundaries. - -### 5.6 Shell-specific syntax -- Explicitly mark Bash-only syntax in the affected level and section intro. - Do not present Bash's `?` or `[...]` globs as portable to Fish. -- For filesystem-only exercises, offer `bash -c 'command'` from Fish, then - submit in the existing wrapped shell. Do not recommend an unwrapped - interactive Bash, which would lose the game's hooks. -- Portable exercises remain usable in Fish. Displayed command replays must - exit successfully, not merely leave a final state that passes validation. -- Use explicit symbolic `chmod` targets (`u+x`, `a-w`) so the taught outcome - does not depend on the player's `umask`. - -### 5.7 Discovery fixtures -- Confirm discovery answers using the actual tool taught by the lesson. - Binary signatures alone do not guarantee that `file` recognizes an image. - Define whether text counts exclude scripts containing `ASCII text`. -- Teach directory types through the first character of a long listing (`ls -l` - or `ls -la` for hidden entries); `d` denotes a directory. Counting fixtures - must remove extra entries on reset so their answers stay - consistent, and decoy counts should differ from the correct count. - ---- - -## 6) CLI behavior constraints: `repeat` and `show` -- `repeat` can re-display arbitrary level/section content via options. -- `show` is intentionally restricted: - - only current level (`--level`) OR current section intro (`--section`) - - does not accept arbitrary IDs -- Validate exactly **one** flag is provided. -- Keep wording concise and user-friendly. -- When adding a command, also add it to the completion word list in **both** - `bash_integration.template` and `fish_integration.template`. - ---- - -## 7) How to work effectively (agent checklist) - -When implementing a change: - -1. Identify the category: - - **UI text/panels** → `ui/display.py` - - **shell / teleport / subshell** → `cli/commands.py` - - **level rules** → `levels/sections/section*.py` - - **shared answer/requirement rule** → `levels/completion.py` - - **workspace setup/reset declaration** → `levels/fixture.py` - - **`cd` restriction** → `levels/cdpolicy.py` - - **reference solution** → `levels/solution.py` - - **intro content** → `levels/content/*.md` - - **progress/persistence** → `state/manager.py` - - When adding a level or a section, follow `docs/AUTHORING.md`. - -2. Preserve established UX: - - use `Display.note(...)` for helper notes - - use `Display.wait_for_continue()` for pauses - - keep Czech text short; style commands in violet when shown as tips - -3. After changes, run a quick regression pass: - - Run tests: `make test` - - Check linting: `make lint` - - Verify `shellgame hint` and `shellgame hint --repeat` behaviors above - - submit behavior when `answer is None` - - teleport notice prints only on actual cwd change - - `.0` intro paging via `---` - - bash/fish startup suppression still holds - ---- - -## 8) Known gotchas (avoid regressions) - -- `_teleport_notice(destination: Path)` signature must not change. -- Ensure `shell.cd(...)` is always invoked with parentheses. -- A level may make its own files read-only (`chmod`). Fixtures must still be - able to restore them, so `shellgame reset` keeps working — replace files, - never write into an existing read-only one. -- Fixtures repair file/directory mixups at declared paths, including parent - directories, without replacing their root or following symlinks. - `DirectoryFixture` may unlink and rebuild a symlink at one of its declared - path components, but must never target the fixture root or follow or modify - the symlink target. - `overwrite=False` preserves existing regular files, not incorrect types. - Custom generated files should also use `FileFixture` for this recovery. - Fixture cleanup and replacement recursively recover traverse/write permissions - so `reset()` succeeds even after `chmod 000` or read-only directories. -- Use `DirectoryPermissionMode` for an exact directory mode. Like every - filesystem requirement, its path is section-root-relative and the level must - declare a `Solution`; `Chmod` works for directory solution steps. -- `Level.prepare()` cleans up accidental non-directory files and restores - `stat.S_IRWXU` on the section root directory if damaged, ensuring `shellgame reset` - works even if the player damages the section root itself. -- `Messages.FILE_READ_ERROR` must always include the `shellgame reset` recovery tip. -- Hints should scaffold progressively (Concept -> Scaffolding/structure -> Specific command) - rather than revealing the full command in Hint 1. -- `FileExists` and `DirectoryExists` with `should_exist=False` check that no - entry (file, directory, or symlink) remains at the path. -- Structural invariant tests must discover levels through - `get_registry().list_levels()`, never by grepping method source. Source-based - discovery silently stops covering a level the moment its behaviour moves from - a hand-written method to a declaration. -- Never blind-regex the section modules: they mix code paths with user-facing - Czech text containing the same literals. Edit them AST-precisely or by hand. - -### Shell integration gotchas (IMPORTANT) -- **NEVER** add `--norc` to bash launch — it disables `--rcfile`. -- **NEVER** split bash into two templates (integration + rc) — use one template directly as rcfile. -- **NEVER** call `shellgame` in fish init-command directly — autostart must be inside the template after the function is defined. -- **NEVER** emit `shell.exit()` from `remove` — the wrapper handles exit directly to avoid protocol leakage. -- Protocol directives go to **stderr only** — do not emit to stdout. -- Scripts that must preserve inherited stdout/stderr use the executable path - in `$SHELLGAME_FD_HOOK`; do not call the exported `shellgame` wrapper, which - captures stderr by design. -- Marker files live in `state.workspace`; do not reconstruct workspace paths from `$USER`. -- The shell templates read the workspace **only** from `$SHELLGAME_WORKSPACE`. - Never reintroduce a `/tmp/shellgame-$USER` fallback: a stale or foreign - directory could then collect evidence markers. -- The bash protocol dispatch relies on word splitting; keep it wrapped in - `set -f` / `set +f` so a payload can never glob against the filesystem. -- Command-specific tasks that distinguish *how* an outcome is reached must use - a level hook plus marker evidence; final-state validation is sufficient only - when equivalent commands are intentionally accepted. -- Fish and bash templates should follow the same pattern: setup → functions → autostart at end. -- Do not label every filesystem exception as a deleted cwd. Show the actual - filesystem failure; use `Messages.CWD_MISSING` only when cwd is unavailable. - -### Isolated playtesting -- Use a throwaway HOME, XDG_CONFIG_HOME, XDG_DATA_HOME, XDG_CACHE_HOME and - workspace. Set them before importing modules that create global services. -- Create a new `StateManager` after isolation is configured, and assert its - `state_file` is inside the owned temporary root before any save or CLI run. - Changing environment variables does not relocate an existing manager. -- Never read, overwrite, remove or restore a player's real save during - playtesting. Clean only paths created within the isolated test root. - ---- - -## 9) Maintenance (keep this file current) - -- If you change architectural patterns (e.g., moving configuration from CLI to Level classes), **update this file**. -- If you add new hard UX rules, **add them here**. -- If you introduce new project values or principles, **update this file**. -- This file is the source of truth for future agents; keep it accurate. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 734618b..9008a5a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,72 +8,332 @@ on: permissions: contents: read +env: + CARGO_TERM_COLOR: always + # macOS archives are built on every release run, but they are only attached to the GitHub release + # when this is "true". Flip it once the macOS content review (GNU-only spots in the levels such + # as `ls --help`, `wc` padding, `file` wording and bracket globs under locale collation) has made + # the macOS level replay green and the macOS `Test` step is blocking. + PUBLISH_MACOS: "false" + +# Third-party actions are pinned to a commit (the tag it had in the comment); GitHub's own to a tag. jobs: + # The tests drive real interactive shells, so every job has a timeout: a hang must not burn the + # 6 hour default. test: - runs-on: ubuntu-latest + name: test (${{ matrix.runner }}) + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 strategy: + fail-fast: false matrix: - python-version: ["3.10", "3.14"] + # The oldest supported fish (3.3) comes with ubuntu-22.04; there the level replays whose + # solutions use `$(...)` (fish 3.4) are skipped. Formatting, docs and lints do not depend + # on the tools, so only the `lint` leg runs them. + include: + - runner: ubuntu-24.04 + lint: true + - runner: ubuntu-22.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 + + # rust-toolchain.toml pins the toolchain and its components (rustfmt, clippy). Depending on + # the rustup version, `show` either installs them itself or only reports that the toolchain + # is missing; in that case `toolchain install` (no arguments) installs what the file asks for. + - name: Install pinned Rust toolchain + run: rustup show active-toolchain || rustup toolchain install - - uses: astral-sh/setup-uv@v6 + # The default cache key has the job id, which both legs share, but not the runner image: add + # it, so artifacts built against one image's glibc are not restored on the other. + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: - python-version: ${{ matrix.python-version }} + key: ${{ matrix.runner }} - name: Install shell tooling run: sudo apt-get update && sudo apt-get install --yes fish shellcheck - - name: Install dependencies - run: uv sync --extra dev + # The sandbox needs Landlock. Print what the runner offers so a failure is easy to read. + - name: Show environment + run: | + uname -r + cat /sys/kernel/security/lsm || true + bash --version | sed -n '1p' + fish --version + shellcheck --version | sed -n '1,2p' - - name: Lint and type-check - run: make lint + # Every tracked script, including scripts/install.sh and tests/support/mock_shellgame.sh, + # which no test checks (`cargo test` checks the game's own bash script). + - name: Lint shell scripts + if: matrix.lint + run: git ls-files -z '*.sh' | xargs -0 shellcheck - name: Check formatting - run: make format-check + if: matrix.lint + run: cargo fmt --check - - name: Run tests - run: make test + - name: Check docs + if: matrix.lint + run: cargo doc --no-deps --locked + env: + RUSTDOCFLAGS: -D warnings - build-and-release: - if: github.event_name == 'push' - needs: test - runs-on: ubuntu-latest - permissions: - contents: write + - name: Lint + if: matrix.lint + run: cargo clippy --all-targets --locked -- -D warnings + + # "required" turns "Landlock is unavailable" from a skip into a failure, so the sandbox tests + # cannot be skipped unnoticed on these runners. + # --no-fail-fast: a failing suite must not hide the failures of the suites after it. + - name: Test + env: + SHELLGAME_TEST_LANDLOCK: required + run: cargo test --locked --no-fail-fast + + # Students play in a Czech locale, which changes the output of tools the levels lean on + # (sort order, `ls --help`, bash's own messages): replay the course there too. + - name: Test the course in the Czech locale + if: matrix.lint + env: + SHELLGAME_TEST_LANDLOCK: required + SHELLGAME_TEST_LOCALE: cs_CZ.UTF-8 + run: | + sudo locale-gen cs_CZ.UTF-8 + cargo test --locked --test e2e + + # Players on macOS get the system bash 3.2, the oldest bash the integration script supports: the + # shell tests run there and block a release. The rest is non-blocking until the macOS content + # review has made the macOS level replay green; then drop `continue-on-error` from the `Test` + # step and the macOS `build` legs, and flip PUBLISH_MACOS above. There is no Landlock on macOS: + # the sandbox tests skip there and game shells start with `--unsafe`. + test-macos: + runs-on: macos-latest + timeout-minutes: 30 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: astral-sh/setup-uv@v6 - with: - python-version: "3.12" + - name: Install pinned Rust toolchain + run: rustup show active-toolchain || rustup toolchain install + + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + + - name: Install shell tooling + run: brew install fish shellcheck - - name: Install build dependencies - run: uv sync --extra build + # Runner images (and Homebrew) can put a bash 5 first on PATH, which would hide bash 3.2 + # incompatibilities, so shadow it with /bin/bash. $GITHUB_PATH entries apply to the following + # steps only, hence the separate check step. + - name: Put the system bash first on PATH + run: | + mkdir -p "$RUNNER_TEMP/system-bash" + ln -sf /bin/bash "$RUNNER_TEMP/system-bash/bash" + echo "$RUNNER_TEMP/system-bash" >> "$GITHUB_PATH" - - name: Build Linux binary + - name: Check shell versions + run: | + version="$(bash -c 'echo "$BASH_VERSION"')" + echo "bash: $(command -v bash) ($version)" + fish --version + shellcheck --version | sed -n '1,2p' + case "$version" in + 3.2.*) ;; + *) + echo "::error::expected the system bash 3.2 first on PATH, found $version" + exit 1 + ;; + esac + + - name: Shell integration with bash 3.2 + run: cargo test --locked --test shell + + - name: Test + continue-on-error: true + run: cargo test --locked + + # Release binaries, one per target, each built on a native runner. Runs on pushes to the default + # branch and on manual dispatch (a dry run on any branch: the archives are uploaded as workflow + # artifacts but nothing is published). + build: + name: build (${{ matrix.target }}) + if: >- + github.event_name == 'workflow_dispatch' || + (github.event_name == 'push' && + github.ref == format('refs/heads/{0}', github.event.repository.default_branch)) + needs: [test, test-macos] + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + # macOS legs must not block Linux releases before the macOS content review of the levels; + # drop this together with flipping PUBLISH_MACOS. + continue-on-error: ${{ matrix.os == 'macos' }} + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + target: x86_64-unknown-linux-musl + os: linux + arch: x86_64 + - runner: ubuntu-24.04-arm + target: aarch64-unknown-linux-musl + os: linux + arch: aarch64 + # The arm64 macOS runner builds both macOS architectures. + - runner: macos-latest + target: aarch64-apple-darwin + os: macos + arch: aarch64 + - runner: macos-latest + target: x86_64-apple-darwin + os: macos + arch: x86_64 + env: + # Embedded into the binary at compile time (option_env!) and reported by `--version`. + SHELLGAME_BUILD: build-${{ github.run_number }} + TARGET: ${{ matrix.target }} + ASSET_NAME: shellgame-${{ matrix.os }}-${{ matrix.arch }}.tar.gz + + steps: + - uses: actions/checkout@v7 + + - name: Install pinned Rust toolchain + run: rustup show active-toolchain || rustup toolchain install + + - name: Add compilation target + run: rustup target add "$TARGET" + + # No build cache: release archives are built from a clean target directory. + - name: Build + run: cargo build --release --locked --target "$TARGET" + + # Every leg runs on a native runner except x86_64 macOS, whose binary runs under Rosetta when + # the arm64 runner has it. The output of `--version` must carry SHELLGAME_BUILD. + - name: Smoke test + run: | + if [ "$TARGET" = x86_64-apple-darwin ]; then + arch -x86_64 /usr/bin/true || + { echo "::notice::No Rosetta, smoke test skipped"; exit 0; } + set -- arch -x86_64 + fi + out="$("$@" "target/$TARGET/release/shellgame" --version)"; echo "$out" + echo "$out" | grep -qF "$SHELLGAME_BUILD" || + { echo "::error::--version does not report $SHELLGAME_BUILD"; exit 1; } + + # The released Linux binary must be static and must start a confined game shell: a command + # typed there runs, the game saves its state, and a write to the home is refused. Without + # Landlock on the runner (the test job requires it only on x86_64) it starts with --unsafe. + - name: Smoke test the game shell + if: matrix.os == 'linux' + shell: bash + run: | + bin="target/$TARGET/release/shellgame" + file "$bin" + file "$bin" | grep -Eq 'statically linked|static-pie linked' || + { echo "::error::$bin is not statically linked"; exit 1; } + lsm="$(cat /sys/kernel/security/lsm 2>/dev/null || true)" + echo "LSMs: $lsm" + set -- + case ",$lsm," in + *,landlock,*) ;; + *) echo "::warning::No Landlock on this runner: the game shell starts with --unsafe"; set -- --unsafe ;; + esac + # Home and state outside /tmp, so that the sandbox has something to protect. + smoke="$RUNNER_TEMP/smoke" + ws="/tmp/shellgame-smoke-$GITHUB_RUN_ID" + mkdir -p "$smoke/home" "$smoke/state" + printf '%s\n' 'touch "$SHELLGAME_WORKSPACE/typed"' 'echo x > "$HOME/escaped"' exit | + env -i PATH="$PATH" HOME="$smoke/home" USER=smoke LC_ALL=C.UTF-8 \ + SHELLGAME_STATE_DIR="$smoke/state" SHELLGAME_WORKSPACE="$ws" \ + "$bin" "$@" --shell bash + test -f "$ws/typed" || + { echo "::error::the game shell did not run the typed command"; exit 1; } + grep -q '"version": 3' "$smoke/state/state.json" || + { echo "::error::the game saved no state"; exit 1; } + if [ "$#" -eq 0 ] && [ -e "$smoke/home/escaped" ]; then + echo "::error::the game shell wrote to the home: it is not confined"; exit 1 + fi + rm -rf "$ws" + + # One `shellgame` file per archive: install.sh and `shellgame update` extract exactly that. + - name: Package + env: + COPYFILE_DISABLE: "1" # keep bsdtar on macOS from adding AppleDouble (._shellgame) entries run: | - uv run pyinstaller shellgame.spec mkdir -p dist/package - cp dist/shellgame dist/package/shellgame - tar -czf shellgame-linux-x86_64.tar.gz -C dist/package shellgame + cp "target/${TARGET}/release/shellgame" dist/package/shellgame + tar -czf "$ASSET_NAME" -C dist/package shellgame + contents="$(tar -tzf "$ASSET_NAME")" + echo "$ASSET_NAME: $contents" + if [ "$contents" != "shellgame" ]; then + echo "::error::$ASSET_NAME must contain exactly one file, shellgame" + exit 1 + fi - - name: Upload release archive - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: - name: shellgame-linux-x86_64.tar.gz - path: shellgame-linux-x86_64.tar.gz + name: ${{ env.ASSET_NAME }} + path: ${{ env.ASSET_NAME }} + if-no-files-found: error + + # Publishes the archives of one run as one GitHub release. install.sh downloads + # releases/latest/download/shellgame-<os>-<arch>.tar.gz, so the asset names are an interface. + # A new release becomes `latest`, so only the newest commit of the default branch is published, + # one run at a time: a run that a newer push overtook, or an old run started again, publishes + # nothing instead of handing students an older build. + publish: + if: >- + github.event_name == 'push' && + github.ref == format('refs/heads/{0}', github.event.repository.default_branch) + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + concurrency: + group: publish + cancel-in-progress: false + permissions: + contents: write + + steps: + - name: Check that this commit is still the tip of the default branch + id: tip + env: + GH_TOKEN: ${{ github.token }} + BRANCH: ${{ github.event.repository.default_branch }} + run: | + tip="$(gh api "repos/$GITHUB_REPOSITORY/commits/$BRANCH" --jq .sha)" + if [ "$tip" = "$GITHUB_SHA" ]; then + echo "newest=true" >> "$GITHUB_OUTPUT" + else + echo "::notice::$BRANCH has moved on to $tip: build-$GITHUB_RUN_NUMBER is not published" + echo "newest=false" >> "$GITHUB_OUTPUT" + fi + + - uses: actions/download-artifact@v8 + if: steps.tip.outputs.newest == 'true' + with: + pattern: shellgame-* + path: dist + merge-multiple: true + + - name: Hold back macOS archives + if: steps.tip.outputs.newest == 'true' && env.PUBLISH_MACOS != 'true' + run: rm -fv dist/shellgame-macos-*.tar.gz + + - name: List release assets + if: steps.tip.outputs.newest == 'true' + run: ls -l dist - - name: Publish GitHub release asset - if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch) - uses: softprops/action-gh-release@v2 + - name: Publish GitHub release + if: steps.tip.outputs.newest == 'true' + uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 with: tag_name: build-${{ github.run_number }} name: ShellGame build ${{ github.run_number }} - files: shellgame-linux-x86_64.tar.gz + # Tag the commit that was built, not whatever the default branch points to by now. + target_commitish: ${{ github.sha }} + files: dist/shellgame-*.tar.gz + fail_on_unmatched_files: true draft: false prerelease: false + make_latest: true generate_release_notes: true diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..b89077a --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,65 @@ +# Publishes `cargo doc` (the README as the front page, the course with each section's learning +# goals, the code) to GitHub Pages once CI has succeeded on a push to main, for the commit CI tested. +# Needs Settings > Pages > Source: "GitHub Actions". `workflow_run` only fires for this file as it is +# on the default branch. +name: Docs + +on: + workflow_run: + workflows: [CI] + types: [completed] + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push') + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.event.workflow_run.head_sha || github.sha }} + + - name: Install pinned Rust toolchain + run: rustup show active-toolchain || rustup toolchain install + + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + + - name: Build docs + run: cargo doc --no-deps --locked + env: + RUSTDOCFLAGS: -D warnings + + - name: Add a root page + run: | + rm -f target/doc/.lock + echo '<meta http-equiv="refresh" content="0; url=shellgame/index.html">' > target/doc/index.html + + - uses: actions/upload-pages-artifact@v5 + with: + path: target/doc + + deploy: + needs: build + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v5 diff --git a/.gitignore b/.gitignore index e84a171..d8b26c4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,102 +1,19 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# Python cache files (redundant but harmless) -.pyc -.pyo - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# uv (package manager + cache) -.uv/ -.uv-cache/ - -# Virtual environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# setuptools-scm -_version.py +# Rust build output +/target -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -# NOTE: Do not ignore *.spec — we commit `shellgame.spec` as part of the release build. - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# pytest-xdist -.pytest_cache/v/cache/lastfailed -.pytest_cache/v/cache/nodeids - -# Type checkers / linters -.mypy_cache/ -.dmypy.json -dmypy.json -.pyright/ -.pylint.d/ -.ruff_cache/ -.pyre/ -.pytype/ +# Python caches from the golden-file helper scripts in tests/state/ +__pycache__/ +.venv/ -# IDEs +# Editors and IDEs .vscode/ .idea/ +*.swp +*~ # OS files .DS_Store -.AppleDouble -.LSOverride -*~ -.fuse_hidden* -.directory Thumbs.db -ehthumbs.db -Desktop.ini -# Logs (project-wide) -*.log +# Per-user agent settings +.claude/settings.local.json diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2e35488 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,234 @@ +# AGENTS.md + +Guide for coding agents and human contributors, and the single source of truth for working in this +repository; GitHub Copilot and Claude Code (2.1.277 or newer) both read it natively. Players: see [README.md](README.md). + +## What this is + +ShellGame is a Czech-language terminal course for first-semester students: filesystem navigation and +basic shell work in short levels, played inside a wrapped bash or fish shell. One Rust binary (edition +2024, toolchain pinned in `rust-toolchain.toml`, `Cargo.lock` committed, CI builds with `--locked`). +Player-facing text is Czech; identifiers, comments, docs and commit messages are English. + +Outside the game shell, `shellgame` starts bash or fish, confined by Landlock, with an integration script +sourced. Inside it, every `shellgame ...` command is a short-lived process started by a shell function. +The binary owns all rules (levels, grading, state) and tells the shell what to do with directives on +stderr; the shell reports `cd` and `pwd` back through hooks. + +## Layout + +| Path | Holds | +| --- | --- | +| `src/levels/` | the course. `mod.rs` is the only registry (`COURSE`); one file per section, named by position: `s00_welcome.rs` … `s11_search.rs` (moving a section means renaming its file and its line in `COURSE`), each opening with a `//!` goals block | +| `src/game/` | the engine: `Level` and `Section`, setup, answers, checks, `cd` policies, path containment (`paths.rs`), shared messages | +| `src/session.rs`, `src/session/update.rs`, `src/cli.rs`, `src/env.rs` | every player command (including `resume`, `levels` and `update`) and the hook commands; clap definitions with Czech help; the process environment, read once in `main` | +| `src/state.rs` | `state.json` v3 | +| `src/ui/` | terminal output: markdown, panels, prompts, screens | +| `src/shell/` | `bash.sh`, `fish.fish`, shell detection, launching, the directive protocol (wire format: module docs of `src/shell/mod.rs`) | +| `src/sandbox.rs` | Landlock confinement and the `chmod` guard | +| `tests/` | `support/` (sandboxed env, binary runner, single-process shell runner), `e2e.rs`, `cli.rs`, `shell.rs`, `sandbox.rs` | +| `scripts/install.sh`, `.github/workflows/` | installer; CI and release (`ci.yml`), the docs site (`docs.yml`) | +| `.claude/skills/` | skills `add-level`, `playtest`, `release` (read by Claude Code and GitHub Copilot) | + +## Commands + +```bash +cargo run # play from source; add -- --shell fish for fish +cargo test # all suites; needs bash (fish, shellcheck, python3, curl, tar optional) +cargo test --test e2e -- solutions::3.2:: # replay level 3.2's solution in bash and fish +cargo clippy --all-targets -- -D warnings +RUSTDOCFLAGS="-D warnings" cargo doc --no-deps # the docs site: a broken link between sections fails it +cargo fmt # CI runs fmt --check, clippy and test with --locked +``` + +Run fmt, clippy and test before you finish. Run cargo in an ordinary terminal, not inside a game shell +(it cannot write `target/`). Tests that need a missing tool skip with a note; the sandbox tests skip when +Landlock is unavailable (containers, some agent sandboxes) unless `SHELLGAME_TEST_LANDLOCK=required`, +which CI sets. Without Landlock (macOS, some containers) play with `cargo run -- --unsafe`. Play by hand +with the `playtest` skill (throwaway HOME, state directory and workspace); never read, change or delete +a player's real save in `~/.config/shellgame`. + +## Levels + +Use the `add-level` skill. A section is one file; a level is one struct literal in its `levels` list: + +```rust +// src/levels/s02_files.rs, listed once, in order, in `COURSE` in src/levels/mod.rs +//! Working with files: moving between directories with purpose, and reading and creating files. +//! ...goals block: purpose, learning objectives, Introduces / Relies on / Leaves for later... +#[rustfmt::skip] +pub const SECTION: Section = Section { + title: "Práce se soubory", // no number: the UI adds "Sekce 2" + intro: r#" + ...markdown; pages are separated by a line `---`... + "#, + levels: &[ + Level { + title: "Integrační výzva", + text: r#" + ### Výzva + 1. Začínáte v `level-2`. Přejděte do `challenge/room1`. + "#, + hints: &["Ze startu přejděte do 'challenge/room1' a přečtěte 'hint.txt'.", "..."], + setup: &[file("challenge/room1/hint.txt", "Heslo je v room2.\n"), + file("challenge/room2/password.txt", "navigator\n")], + answer: exact("navigator").nocase().error("Heslo není správné. Hledejte password.txt v room2."), + check: &[at("challenge/room2")], + solution: r#" + cd challenge/room1 + cd ../room2 + shellgame submit "$(cat password.txt)" + "#, + success: "Výborně! Umíte propojit navigaci se čtením souborů.", + ..LEVEL + }, + ], +}; +``` + +- **Section goals.** Every section file starts with a `//!` goals block (copy the shape of a neighbour; + it is the course outline on the docs site): purpose, learning objectives, then *Introduces*, *Relies + on* and *Leaves for later*. A new or changed level must serve its section's objectives and must not + ask the player for what the section leaves for later (a later tool may be handed over ready-made, as + `file *` is in `s05_inspect`; list it under *Relies on*). Update the block in the same + change whenever a section's scope changes. Name other sections by intra-doc link, + `[navigation](super::s01_navigation)`, never by number, so `cargo doc` (above) catches stale + references after a move. +- **Positions, not ids.** "S.L" is the L-th level of the S-th section in `COURSE`; section S works in + `<workspace>/level-S`. Saves store positions, so inserting, moving or removing a level or section + shifts later saved games (accepted; say so in the commit message). Texts, hints, answers and + solutions may mention only their own section's number (`level-N`, `Sekce N`; a test enforces it for + texts) and it must match the section's position, so never blind search-and-replace after a move. + Name another section in a text, and a level in a comment, by its title, never by number. No ids, + macros, builders or data formats; ask the maintainer before changing the level format. +- **Fields:** `title, text, hints, start, setup, answer, check, cd, solution, success`; `..LEVEL` + supplies the rest (`start: "."`, no answer, `success: "Správně!"`). A level needs an answer or a + check; without an answer it is submitted bare, `shellgame submit`, once its checks hold, and its + text and solution say so. Paths, `start` included, are relative to the section directory; a course + test rejects absolute paths and `..`. +- **Text.** `text`, `intro` and `solution` are indented raw strings dedented at runtime: content starts on + the line after `r#"` and is indented with spaces only (tests enforce both); `file` contents, `content_is` + expectations and `success` may be such blocks too, and file contents and expectations get a final + newline. Markdown: headings, lists, code blocks (never wrapped), no tables. Hints are plain text, + printed literally. Intro pages are split by `---` and continue with Enter; never tell the player to run + `shellgame submit` there, and don't start an intro with a heading: the panel title already shows + "Sekce N: <title>" (a test enforces it). Write navigation relative to the start directory and never a + user name. +- **Hints escalate:** the idea, the shape of the command, then the command; hint 1 is never the whole + solution. For discovery tasks they explain how to find the answer, never the answer. Answers are natural + discoveries (names, contents, counts, sizes), never artificial tokens. +- **Never put an answer into an `error`, `mistakes` or `format` text**, or into a failed + check's message, and do not name the target directory in a failed `at(..)` message (a test catches + literal leaks). The default reply to a wrong answer is the terse `Nesprávně.`. +- **Vocabulary** (constructors, then modifiers; details in `src/game/{setup,answer,check,cd}.rs`): + - setup: `dir, file, binary, clean, build(fn(&Path))`; `.mode(0o..)` + - answer: `exact, one_of, int, suffix, parts(sep, &[..])`; `.nocase() .mistakes(&[..]) .error() + .format()`. A submission loses surrounding spaces and trailing slashes (Tab completion adds one); + a text that is no number is a plain wrong answer; `.format()` is also the reply to a missing answer. + - check (each is a `Result<(), String>`): `at, at_home, is_file, is_dir, no_file, content_is, contains, + same, moved, mode_is, mode_has, mode_lacks, pwd_used, cd_done`; `.msg() .missing()`: the failure + text, and the text when the file (`same`: the copy, `moved`: the destination) is missing. Checks run + before the answer, so a correct answer never bypasses them; `cd_done` needs `cd` clauses. + - cd (a flat list, for levels that practise one exact movement): `cd::when_in` chooses the moves it + judges; `cd::exact, cd::absolute, cd::within` block the others with a message; `cd::arrive` counts + the drill only when the player lands in the given directory; `cd::custom(rule, track)` is for + stateful drills. +- **Solution (required).** The player's moves, one command per line, from the level's start + directory, ending in `shellgame submit [answer]`. Tests replay it through the real game shell in bash + (umask 077) and in fish, on a fresh level and again after a re-prepare (what `reset` meets); it must + run unchanged in both (bash-only syntax goes through `bash -c '...'`, as in the wildcards). Derive + answers with commands, not by typing them: the ones the level teaches (`grep`, `wc`, `file`), plus + plumbing (`cut`, `sed`) where the player would read the answer off the screen. Use `"$(...)"` and + `"$PWD"`; avoid `[[ ]]`, `$'..'`, `local`, `export X=1`; fish needs 3.4 for `$(...)`, older fish skips + the replay of such a solution. No `less`, prompts or `sleep`. Use explicit symbolic `chmod` (`u+x`, + `a-w`) so results do not depend on umask. +- **Custom logic** is a plain `fn` next to the section, referenced from the literal: `build` and + `cd::custom` (the maze, the home walk, the server log). Use it only where the vocabulary falls short; + no validation reasons about it. Data stays in the literal (file contents, expectations, messages, + tables): a fn holds only logic, and data it needs is passed in from the literal, as the maze's plan is. +- **Setup must survive the player.** `reset` re-applies it after anything (`chmod 000`, a file where a + directory belongs, a symlink). Declare what the player may create with `clean`, declare `.mode(..)` + for anything whose permissions are graded, and verify discovery files with the command the lesson + teaches (magic bytes alone may not make `file` say "image"). + +## Invariants + +1. **Sandbox.** The game shell and everything it starts may change only `/tmp`, the state directory and + `/dev` (Landlock, applied in the launcher between fork and exec; a typed `chmod` goes through + `shellgame guard`). Never widen this or add a bypass: `shellgame --unsafe` is the only opt-out, and + without Landlock the game refuses to start. Rights are `AccessFs::from_write(ABI::V5)`, best effort + on older kernels; do not ask for newer ABIs (V9 breaks X11, Wayland, D-Bus and ssh-agent sockets) or + signal scoping (RHEL 9 misreports it). The workspace (also `$SHELLGAME_WORKSPACE`) must resolve inside + `/tmp` and belong to the user; the state directory must not be `$HOME` or an ancestor of it. +2. **Thin shell.** `src/shell/bash.sh` and `fish.fish` only forward events and replay directives; all + logic lives in the binary. Directives (`__SHELLGAME_EXEC__v1 <verb> [base64...]`, a closed set of + `cd`, `export` and `exit`, never `eval`) go to stderr only. Paths reach the shell through environment + variables, never pasted into script text. The `cd` hook is one static function: `cd-hook pre` exiting + 3 blocks, anything else allows, so a broken hook never locks a player in. Autostart is the last block + of each script. Keep both shells in parity: change both scripts and test the change in both. bash 3.2 + compatible (no associative arrays, `mapfile`, `${x,,}`, `&>>`, `|&`); fish 3.3 compatible (nothing + from 3.4 or 4). bash starts with `--rcfile`, fish with `--no-config`; never `--norc` or + `--noprofile`. +3. **Workspace containment.** All game file operations (setup, checks, `reset`, `remove`) go through the + containment helpers in `src/game/paths.rs`: declared paths stay inside the section directory, + symlinks are never followed, `remove` deletes only a workspace strictly inside `/tmp` (`paths::inside_tmp`, + which the `chmod` guard shares). Do not write a second implementation. +4. **`reset` always rescues the player.** It repairs the level's setup whatever the player did (the + workspace and the section directory included: lost permissions, a file or a symlink in their place) + and moves them to the start directory. Never weaken the repair rules or drop the teleport. +5. **Strict `cd` levels never soft-lock.** The engine guarantees that moving back to the start directory + is always allowed, that every move is allowed once the drill is done, and that a block shows the + `shellgame reset` tip. A course test checks that every policy blocks something and never traps. +6. **State.** `state.json` v3 holds positions only. An older file is renamed to `state-old.json` (never + overwritten) and a fresh game starts, with a notice that names the position to resume when the old + file says where the player was; a newer, corrupt or unsupported file is an error and is never + overwritten (only `remove` deletes it). A saved position that no longer exists resumes at the nearest + following level. Saves are atomic (temporary file, rename). +7. **No level specifics in general code.** Code outside `src/levels/` never refers to a particular + level or section (no positions, titles, file names, or mechanisms that exist for one level); a + level's special needs are met with its own data or a fn in its section file. Validation tests are + generic: no per-level exceptions or allowlists. + +## Code + +Compactness over performance. `rustfmt.toml` sets `use_small_heuristics = "Max"`; course sections are +`#[rustfmt::skip]`. Doc comments only for non-obvious contracts. One error style: `io::Error` with +context for real failures, `Result<(), String>` for feedback shown to the player. No Python-parity +hacks. New dependencies need a reason (chrono and indexmap were dropped on purpose; timestamps are unix +seconds). + +Multi-line text is a raw string with real line breaks, never `\n` escapes, and a raw string also spares +`\"`. In indented code it is an indented block that starts on the line after `r#"` and is dedented at +runtime, as level `text` is; at module level, a raw string starting at column 0. Keep `\n` for a single +trailing newline, separators in code (`join("\n")`), control bytes and whitespace-exact test inputs. + +Every shell script passes shellcheck at its default severity: `tests/shell.rs` checks the bash +script, CI every tracked `*.sh`. A `# shellcheck disable=` needs a reason on the line above. Level +solutions are typed commands (`cd dir`, `ls | grep`), not scripts, and are not shellchecked. + +## Tests + +Only tests that protect players: solution replay in real bash and fish (`tests/e2e.rs`: every level, a +full playthrough per shell, strict `cd` scenarios), the sandbox (`tests/sandbox.rs`), CLI flows +(`tests/cli.rs`), the shell protocol and hooks (`tests/shell.rs`: both scripts in real shells, shellcheck), +course validation (generic checks over every level), and a few unit tests for tricky pure +code (setup repair, containment, answer order, `same`/`moved`, `cd` lifting, markdown widths). Prefer +one generic data-driven check to per-level cases. Do not add tests that pin wording or allowlists of +current content, grep source text, or mock the thing under test. A new level needs no new test: its +solution and the course validation are the test, and when validation fails it is usually right. + +## Release + +A push to `main` builds Linux x86_64 and aarch64 (static musl) and macOS archives and publishes release +`build-N` (N is the CI run number) with `shellgame-linux-{x86_64,aarch64}.tar.gz`. macOS archives are held +back by `PUBLISH_MACOS` in `.github/workflows/ci.yml` until a macOS content review. A dry run is the CI +workflow started with `workflow_dispatch` on any branch: it builds everything and publishes nothing. +`scripts/install.sh` and `shellgame update` download `releases/latest/download/shellgame-<os>-<arch>.tar.gz`, +so asset names are an interface. The same push publishes the docs site (`cargo doc`, the README as its +front page) to GitHub Pages through `.github/workflows/docs.yml`. See the `release` skill. + +## Commits + +Keep commits focused: one concern each, an imperative English subject, a body that says what and why. +Commit only when asked. Never push, merge or tag without the maintainer asking: a push to `main` +publishes to students. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md deleted file mode 100644 index ac4e022..0000000 --- a/ARCHITECTURE.md +++ /dev/null @@ -1,179 +0,0 @@ -# ShellGame Architecture - -ShellGame is a Click application that runs inside a controlled bash or fish -subshell. Python owns all game rules and persistence; shell templates only -forward events and apply a small, versioned command protocol. - -## Runtime flow - -1. `shellgame` creates application services and discovers level modules. -2. Outside a wrapper, `boot_if_needed()` launches bash or fish with the - generated integration script. -3. Inside the wrapper, `GameSession` loads or creates state, prepares the - current level, renders instructions, exports shell context, and teleports - when the level declares a start directory. -4. User commands invoke a new Python process through the shell wrapper. -5. State-changing actions are persisted with one atomic write. - -## Responsibilities - -| Area | Module | Responsibility | -| --- | --- | --- | -| Click interface | `cli/commands.py` | Parse commands and delegate to `GameSession` | -| Orchestration | `core/session.py` | Initialization, display, hints, submit, reset, developer actions | -| Navigation | `core/navigation.py` | Resolve level start directories and emit teleport requests | -| Shell launch | `cli/subshell.py` | Render templates, detect shell, launch wrapper | -| Shell hooks | `cli/hooks.py` | Generate bash/fish `cd` dispatch from level hook metadata | -| Protocol client | `shell/client.py` | Emit versioned, base64-encoded operations to stderr | -| Persistence | `state/manager.py` | Validation, migrations, atomic state replacement | -| Progress | `core/progress.py` | Pure in-memory progress mutations | -| Level lifecycle | `levels/base.py` | Preparation, rendering metadata, custom extension points | -| Level registration | `levels/collector.py` | Section-local stable IDs, roots, and shared fixtures | -| Completion rules | `levels/completion.py` | Typed answers and unconditional gameplay requirements | -| Workspace fixtures | `levels/fixture.py` | Safe, repeatable setup and cleanup declarations | -| `cd` policies | `levels/cdpolicy.py` | Declarative movement rules with structural soft-lock protection | -| Reference solutions | `levels/solution.py` | Author walkthroughs proving a level is completable | -| Path containment | `paths.py` | The one implementation of "stay inside this root" | -| UI | `ui/display.py` | Rich panels, paging, notes, prompts | - -## Shell integration - -### Bash - -```text -bash --rcfile <integration_script> -i -``` - -Do not add `--norc`; it disables `--rcfile`. Do not add `--noprofile`; -the inherited environment must remain available. - -### Fish - -```text -fish --init-command "function fish_greeting; end; source <integration_script>" -``` - -Both templates follow the same order: setup, function definitions, generated -hooks, wrapper function, autostart. - -## Shell protocol - -Python emits protocol lines to stderr: - -```text -__SHELLGAME_EXEC__v1 <verb> [base64-argument...] -``` - -Only `cd`, `export`, `echo`, `pwd`, and `exit` are supported. Templates decode -arguments and dispatch these verbs explicitly; arbitrary shell evaluation is -not part of the protocol. `remove` remains a wrapper-level success case and -exits the subshell without an `exit` directive. - -Commands that must inspect their inherited file descriptors invoke the -temporary executable referenced by `SHELLGAME_FD_HOOK`. They must not call the -shell wrapper function, because that function intentionally captures stderr. - -## Level contracts - -- A section declares its persistent number with `Section(number, ...)`. -- Every level declares its stable suffix locally with `@section.level(number)`; - IDs never depend on declaration order. -- Section intro behavior is declared with `is_intro`. -- Start-directory enforcement is declared by the level. -- Regular levels declare a `Completion`; only genuinely custom interactions - override `validate()`. -- `Completion.requirements` are unconditional. A correct or empty answer never - bypasses filesystem, location, permission, or command-evidence checks. -- Navigation uses `AtDirectory`, which compares the exact resolved - workspace-relative path rather than only the final directory name. -- Reusable answer shapes use typed rules such as `ExactAnswer`, - `IntegerAnswer`, `TupleAnswer`, and `OrderedListAnswer`. -- **Every level-declared path is relative to that level's section root** - - fixtures, requirements, `start_directory` and solution steps all speak one - vocabulary, so a level can never disagree with itself about where it lives. A - level that starts above its own section says so with the `WORKSPACE_ROOT` - sentinel rather than by overloading `""`. -- All four resolve through `paths.resolve_within()`, which rejects absolute - paths, `..` and symlinked parents. It is the single implementation; fixtures, - requirements and section roots share it. -- Default answer feedback never contains the answer. A rule with - `error_message=None` rejects with the terse `Messages.INCORRECT`; anything - more specific is written deliberately by the author. -- Movement restrictions are declared as a `CdPolicy`, not hand-written. The - engine consults `cd_enforcement_lifted()` before every rejection and routes - rejections through `block_cd()`, so the anti-soft-lock guarantee is - structural rather than a convention each author must remember. -- A policy's evidence marker is derived from the level ID, so marker names are - unique by construction and need no global registry. -- Custom `_handle_cd` remains available for genuinely stateful grading (level - 1.9 walks from `/` to `$HOME` one segment at a time) and carries the same - obligations by hand. -- Levels that can silently become unwinnable - custom `validate()` or `setup()`, - a `cd` policy, an evidence marker, or a filesystem requirement - must declare - a `Solution`. It is replayed against a throwaway workspace, before and after - `reset()`. -- `Level.is_bonus` is `optional or extension`. Only bonus levels may be - skipped with `shellgame skip`; core levels never can. -- `Level.prepare()` clears declarative evidence, applies the shared section - fixture, applies the level fixture, and only then calls custom `setup()`. -- Use custom `setup()` only for content that is impractical to declare as a - fixture. -- Gameplay validation belongs to the level class, not CLI or shell code. - -## Session guarantees - -- `GameSession.submit()` shows the level's own `success_message`; it never - substitutes a generic one. -- Advancing performs exactly one atomic save, which also starts the next - level's timer. The save happens before the next level's fixture is applied, - so a failing fixture reports `show_level_setup_error` without losing - progress. -- Finishing the last level sets `completed_at`. After that, `submit`, `skip`, - `hint`, and showing the current level are inert and only re-display the - completion summary. -- `reset()` re-exports the shell context and calls - `ensure_user_in_reasonable_place()`, so it always rescues a player who - wandered out of the level. Fixtures overwrite their files even when the player - revoked write permission, so reset works after a `chmod` level. -- A deleted working directory is a normal, recoverable failure. `Path.cwd()` is - never called unguarded; `paths.current_directory()` returns `None` and rules - report it instead of raising. -- An unknown `current_level` resyncs to the nearest valid level and keeps - `levels_complete`, so renumbering between versions cannot brick a save. - -## Persistence - -State files contain a version and are migrated during loading. Missing state -causes initialization; malformed or unsupported state raises an explicit error -and is never treated as missing. Each action mutates state in memory and then -uses `os.replace()` for one atomic commit. - -Command evidence is not state: markers live in the workspace and are named after -the level that owns them. Renaming a marker therefore never invalidates a save; -at worst a player standing in the middle of that one level repeats a single -move. - -## Quality gates - -```text -make lint -make format-check -make test -``` - -CI runs these gates on the oldest and newest supported Python versions with -bash, fish, and shellcheck available. - -Beyond per-feature tests, several suites assert structural invariants that a -new level cannot opt out of: - -| Suite | Invariant | -| --- | --- | -| `test_path_vocabulary.py` | No level restates its section root or builds a path by hand | -| `test_marker_invariants.py` | Every evidence marker is owned and cleared by exactly one level | -| `test_cd_hooks_no_softlock.py` | No level that can reject a move lacks soft-lock coverage | -| `test_solutions.py` | Risky levels stay completable, before and after `reset()` | -| `test_level_invariants.py` | Start directories exist; default feedback never reveals an answer | -| `test_authoring_docs.py` | Every example in `docs/AUTHORING.md` runs against the real API | - -See [docs/AUTHORING.md](docs/AUTHORING.md) for how to add a level or section. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..e70b1ef --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,456 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "escape8259" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f0d310082fd5de539d4f41601182241147498444a7695c520288a0cdbead6" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "landlock" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cca98e95f35b29d469dade6724c6f96cec9236640f745a0e99b0334ec320ab1" +dependencies = [ + "enumflags2", + "libc", + "thiserror", +] + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libtest-mimic" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14e6ba06f0ade6e504aff834d7c34298e5155c6baca353cc6a4aaff2f9fd7f33" +dependencies = [ + "anstream", + "anstyle", + "clap", + "escape8259", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags", + "memchr", + "unicase", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shellgame" +version = "0.2.0" +dependencies = [ + "anstream", + "anstyle", + "base64", + "clap", + "landlock", + "libtest-mimic", + "pulldown-cmark", + "rustix", + "serde", + "serde_json", + "tempfile", + "unicode-width", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "unicase" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..5cc9643 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "shellgame" +version = "0.2.0" +edition = "2024" +rust-version = "1.99" +description = "Interactive terminal learning experience for filesystem navigation" +license = "GPL-3.0-only" +authors = ["Jakub Dupak <dev@jakubdupak.com>"] +publish = false + +# The end-to-end suite generates one named trial per level and shell (libtest-mimic), so it brings +# its own harness. +[[test]] +name = "e2e" +harness = false + +[dependencies] +anstream = "1.0.0" +anstyle = "1.0.14" +base64 = "0.23.1" +clap = { version = "4.6.7", features = ["derive"] } +pulldown-cmark = { version = "0.13.4", default-features = false } +rustix = { version = "1.1.5", features = ["fs", "process", "termios", "stdio"] } +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" +tempfile = "3.27.0" +unicode-width = "0.2.2" + +[target.'cfg(target_os = "linux")'.dependencies] +landlock = "0.4.7" + +[dev-dependencies] +libtest-mimic = "0.8.2" + +[profile.release] +lto = true +codegen-units = 1 +strip = true +panic = "abort" diff --git a/Makefile b/Makefile deleted file mode 100644 index 53e6b77..0000000 --- a/Makefile +++ /dev/null @@ -1,67 +0,0 @@ -.PHONY: install dev build-deps test lint format format-check check build clean run run-devmode help venv lock - -VENV_DIR ?= .venv -UV ?= uv - -help: - @echo "ShellGame - Makefile commands:" - @echo " make install - Install package (base deps) via uv" - @echo " make dev - Install package with dev deps via uv" - @echo " make lock - Generate/update uv.lock" - @echo " make test - Run tests with coverage (uv run)" - @echo " make lint - Run Ruff and mypy (uv run)" - @echo " make format - Format code with Ruff (uv run)" - @echo " make format-check - Check formatting without changing files" - @echo " make check - Run lint, format check, and tests" - @echo " make build - Build standalone binary with PyInstaller (installs build-only deps)" - @echo " make clean - Clean build artifacts and cache" - @echo " make run - Run ShellGame (normal mode; auto-launches wrapped subshell if needed)" - @echo " make run-devmode - Run ShellGame with --devmode (auto-launches wrapped subshell if needed)" - -$(VENV_DIR): - $(UV) venv $(VENV_DIR) --quiet - -venv: | $(VENV_DIR) - -install: venv - $(UV) sync - -dev: venv - $(UV) sync --extra dev - -build-deps: venv - $(UV) sync --extra build - -lock: - $(UV) lock - -test: dev - $(UV) run pytest tests/ -v --cov=shellgame --cov-report=term-missing - -lint: dev - $(UV) run ruff check src/shellgame/ tests/ - $(UV) run mypy src/shellgame/ - -format: dev - $(UV) run ruff format src/shellgame/ tests/ - -format-check: dev - $(UV) run ruff format --check src/shellgame/ tests/ - -check: lint format-check test - -build: build-deps - $(UV) run pyinstaller shellgame.spec - -clean: - rm -rf build/ dist/ *.egg-info .pytest_cache .mypy_cache .coverage - find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true - find . -type f -name "*.pyc" -delete - -run: dev - # Normal run (no devmode). This will still auto-launch the wrapped subshell if needed. - $(UV) run shellgame - -run-devmode: dev - # Dev mode. This will still auto-launch the wrapped subshell if needed. - $(UV) run shellgame --devmode diff --git a/README.md b/README.md index 105d23f..acb6de4 100644 --- a/README.md +++ b/README.md @@ -1,211 +1,113 @@ # ShellGame -> [!WARNING] -> This project is **largely LLM-generated** and is in an **early stage of development**. -> -> Proceed with caution, and expect significant changes. +ShellGame is a terminal course for first-semester students; the game itself is in Czech. It teaches the +shell by doing: navigation (`pwd`, `ls`, `cd`, absolute and relative paths), creating, copying and +removing files, hidden files, permissions, wildcards, redirection and pipes, `grep` and `find`. -Interactive terminal learning experience for filesystem navigation and basic shell operations. +It is one Rust binary. `shellgame` starts a wrapped bash or fish shell with the game built in, so there +is nothing to `source`. Everything you type there is a normal shell command; the game's own commands +start with `shellgame`. -## What this is +The course outline with each section's learning goals is published with the code documentation: +<https://jdupak.github.io/ShellGame/shellgame/levels/>. -ShellGame is a terminal-based learning tool designed for first-semester CS students to practice shell navigation and common filesystem operations through progressive levels. - -## Quick start (players) - -ShellGame starts a wrapped subshell (bash/fish) with integration enabled automatically. You don’t need to `source` anything. - -Just run: - -```bash -shellgame -``` - -### Manual shell selection (override) - -If you want to explicitly choose which subshell ShellGame launches (useful in nested shells or under wrappers like `uv` / `make`), use: - -```bash -shellgame --shell fish -# or -shellgame --shell bash -``` - -This affects only the initial wrapper launch (when ShellGame is not already running inside the wrapper). - -### First time setup (recommended) - -```bash -make dev -shellgame -``` - -### Playing the game - -Show current level: - -```sh -shellgame -``` - -Get hints (each call reveals one more hint): - -```sh -shellgame hint -``` - -Re-read the hints you already unlocked (does not consume a new one): - -```sh -shellgame hint --repeat -``` - -Submit answers: - -```sh -shellgame submit <your-answer> -``` - -Example: `shellgame submit level-1` - -Skip the current level (only possible on optional and extension levels): - -```sh -shellgame skip -``` - -Check progress: - -```sh -shellgame status -``` - -Reset current level (rebuild workspace for the level, re-show assignment, and -move you back to the level's start directory): - -```sh -shellgame reset -``` - -List all available levels (number and name): - -```sh -shellgame levels -``` - -Resume from a specific level (becomes your saved position, so quitting and -launching `shellgame` again continues from there): - -```sh -shellgame resume <section>.<level> -``` - -Example: `shellgame resume 1.4` - -Running `shellgame resume` without a level lists the available levels. - -Repeat assignment (without resetting): - -```sh -shellgame repeat -``` - -You can also target a section or a specific level: -- `shellgame repeat --section 2` (shows level `2.0`) -- `shellgame repeat --level 1.7` - -Clean up (removes all game data and workspace; requires confirmation): - -```sh -shellgame remove -``` - -### Tips - -- Read the goal carefully — each level has specific requirements -- Use hints wisely — they’re tracked but there to help you learn -- Experiment freely — the workspace is temporary and safe -- Check your location — use `pwd` before submitting - -### Troubleshooting - -"Level not found" -- Restart ShellGame; if the problem persists, remove and recreate the game data. - -Wrong answer -- Re-read the assignment -- Check `pwd` to confirm where you are -- Use `shellgame hint` - -Stuck: a navigation level rejects every `cd` you try -- Some levels practise one exact command, so other moves are refused on purpose. -- `shellgame reset` always takes you back to the level's start directory. -- Once you have used the required command, normal movement is restored. - -Need to start over: - -```bash -shellgame remove -shellgame -``` - -### Learning goals - -ShellGame teaches you to: -- Navigate the filesystem confidently -- Understand absolute vs relative paths -- Use `pwd`, `ls`, `cd` fluently -- Read and follow file-based instructions -- Create, inspect, copy, move, and remove filesystem entries safely -- Understand file and directory permissions, redirection, stdin, stdout, and stderr -- Quote paths containing spaces and react to command success with `&&` and `||` -- Select files with glob patterns and search with `grep` and `find` -- Work efficiently with built-in help, manuals, and short aliases -- Build mental models of directory trees and shell data flow - -## Install (development) - -```bash -git clone https://github.com/jdupak/ShellGame.git -cd ShellGame - -make dev -``` - -Notes: -- This project uses `uv` for dependency management and virtual environments. -- `make dev` will create/sync `.venv` and install the `dev` extras. - -## Install (distribution) - -Install the latest Linux x86_64 binary into the current directory (no root or `sudo` required), then run it directly: +## Install ```bash curl -fsSL https://raw.githubusercontent.com/jdupak/ShellGame/main/scripts/install.sh | bash ./shellgame ``` -To choose another installation directory: - -```bash -curl -fsSL https://raw.githubusercontent.com/jdupak/ShellGame/main/scripts/install.sh | INSTALL_DIR="$HOME/bin" bash -"$HOME/bin/shellgame" -``` - -Or build a standalone binary locally with `PyInstaller`: +The script downloads the latest release for your machine into the current directory. It needs `curl` +and `tar`, no root, and runs in `sh` as well as `bash`. Another directory: +`... | SHELLGAME_INSTALL_DIR="$HOME/bin" bash` (`SHELLGAME_REPO=owner/name` installs from a fork). + +Supported: Linux on x86_64 and aarch64. macOS builds are produced by CI but not published yet: the +macOS review of the levels is not finished. Windows is not supported; use WSL. The game shell is bash +3.2 or newer, or fish 3.3 or newer. + +## Commands + +| Command | What it does | +| --- | --- | +| `shellgame` | start the game; inside the game shell, show the current level | +| `shellgame hint` | reveal one more hint; the ones you unlocked before are shown again with it (`hint -r` shows only those) | +| `shellgame submit <answer>` | submit an answer, e.g. `shellgame submit level-1`; levels graded on your files or location take a bare `shellgame submit` | +| `shellgame status` | progress and statistics | +| `shellgame reset` | rebuild the current level's files, show the assignment, move you back to its start directory | +| `shellgame show` | show the current level's assignment again; `show 1.7` shows level 1.7's, `show 2` the intro of section 2 | +| `shellgame levels` | list every level by section, marking the one you are at and the completed ones | +| `shellgame resume 3.2` | continue at level 3.2 (completed levels keep their records); `resume 3` goes to the first level of section 3 (after its intro). Typed outside the game shell it starts the game shell and runs there | +| `shellgame remove` | delete all game data and the workspace (asks first; `--yes` skips the question) | +| `shellgame exit` | leave the game shell (`exit` works too) | +| `shellgame update` | update to the latest release; run it outside the game shell | +| `shellgame --shell bash` | start the game in bash (or `fish`) instead of the detected shell | +| `shellgame --unsafe` | start without isolation, only for systems without Landlock (see below) | +| `shellgame --version` | print the version and build | + +Progress lives in `state.json` in `~/.config/shellgame` (or `$XDG_CONFIG_HOME/shellgame`); the level +files live in `/tmp/shellgame-$USER`. `SHELLGAME_WORKSPACE` can move them, but only to a directory of +yours inside `/tmp`. `NO_COLOR=1` turns colours off. + +### Continuing on another machine + +Install ShellGame there and continue where you were. `shellgame status` and `shellgame exit` print the +command, for example `shellgame resume 3.2`; `shellgame levels` lists every level. Level numbers are +positions (`3.2` is the second level of section 3). The levels before the one you resume at are not +marked as completed. + +## Isolation + +You type real commands, so a mistyped `rm` could hurt real files. On Linux the game shell and every +program started from it are confined with Landlock: they can change files only in `/tmp`, in the +game's state directory and in `/dev`. Writing, deleting, renaming or creating anything else fails with +"Permission denied", and a line under each level says the isolation is on. + +Landlock also forbids gaining privileges, so `sudo` and `su` do not work inside the game shell; open +another terminal for those. The same goes for `shellgame update`, which replaces the installed binary: +run it in an ordinary terminal. + +Where Landlock is missing (some older or minimal kernels, WSL 1, some containers, macOS) the game +refuses to start and says why. `shellgame --unsafe` starts it anyway, with a red warning; use it only +on a machine you can afford to lose files on. + +The isolation protects against mistakes, not against someone trying to get around it. Known limits: + +- Landlock cannot restrict permission, ownership or timestamp changes. A typed `chmod` outside `/tmp` is + refused by a guard, but `chmod`, `chown` and timestamp changes (for example `touch` on an existing + file) still work through scripts, `find -exec` or `command chmod`. +- `truncate` can still empty files outside `/tmp` on kernels older than 6.2. +- Your own files in `/tmp` can be deleted, including other programs' files there. +- Programs that were already running outside the game keep their access, and other terminal windows + are not confined. + +## Troubleshooting + +- **Stuck: a navigation level rejects every `cd`.** Some levels practise one exact command and refuse + other moves on purpose. `shellgame reset` always takes you back to the level's start directory, and + `cd` back to that directory is always allowed. +- **"Uložená hra pochází ze starší verze".** The old save is kept as `state-old.json` and a new game + starts. Continue where you were with `shellgame resume S.L`, e.g. `shellgame resume 3.2`; the notice + names your level when the old save records it. +- **"… pochází z novější verze ShellGame".** The save was written by a newer release: run + `shellgame update` (outside the game shell). +- **The save file is damaged.** ShellGame never overwrites it. Repair the file named in the message, or + run `shellgame remove` and start over. +- **The workspace is gone** (a reboot cleared `/tmp`). Run `shellgame`: the level files are rebuilt and + your progress is kept. + +## Development ```bash -make build +cargo run # build and play from source; add -- --shell fish for fish +cargo test # needs bash; fish, shellcheck, python3, curl, tar optional +cargo clippy --all-targets -- -D warnings +cargo fmt ``` -Every push to the default branch creates a GitHub Release asset containing a packed `shellgame` binary. - -## Shell integration (built-in) - -ShellGame provides built-in subshell integration automatically on startup (bash/fish). You don’t need to `source` anything. - -Protocol reference: -- `docs/protocols/SHELL_PROTOCOL.md` +`rust-toolchain.toml` pins the toolchain; rustup installs it on first use. Contributors and coding +agents: read [AGENTS.md](https://github.com/jdupak/ShellGame/blob/main/AGENTS.md) first. It covers the repository layout, how to add a level, the +invariants, the testing philosophy and the release flow. ## License diff --git a/docs/AUTHORING.md b/docs/AUTHORING.md deleted file mode 100644 index 0a28415..0000000 --- a/docs/AUTHORING.md +++ /dev/null @@ -1,369 +0,0 @@ -# Authoring ShellGame levels - -This guide covers adding a level or a section. It assumes you have read -`ARCHITECTURE.md` for the wider picture. - -The rule that drives everything below: **a level declares what "solved" looks -like, and the engine decides how to check it.** Hand-written logic is allowed, -but every line of it is a line no invariant test can reason about. - ---- - -## 1. Anatomy of a level - -```python -@section.level(3) -class ExtensionLevel(Level): - title = "Vstup a hlášení" - instructions = """ - ### Cíl - Zjistěte název souboru bez přípony. - """ - hints = ("Zkuste `ls`.", "Podívejte se dovnitř `alpha`.") - start_directory = "" - fixture = WorkspaceFixture(files=(FileFixture("alpha/inside.txt"),)) - completion = Completion( - answer=ExactAnswer("inside"), - requirements=(AtDirectory("alpha"),), - ) - solution = Solution(steps=(Chdir("alpha"),), answer="inside") - success_message = "Přesně tak!" -``` - -`@section.level(3)` assigns the ID. The number is the **stable suffix**, not a -position: reordering the classes in the file does not renumber anything. -IDs live in `state.json`, so changing one invalidates a player's save. - ---- - -## 2. Paths: one vocabulary - -**Every path a level declares is relative to its section root.** The section -root is declared once, by the section: - -```python -section = Section(1, root="level-1") -``` - -That single vocabulary covers `fixture`, `completion` requirements, -`start_directory` and `Solution` steps. A level never repeats its own -`level-N/` prefix: - -```python -start_directory = "alpha" # -> <workspace>/level-1/alpha -AtDirectory("gamma/deep") # -> <workspace>/level-1/gamma/deep -FileFixture("absolute-target/PLACEHOLDER") # -> <workspace>/level-1/absolute-target/… -``` - -`tests/test_path_vocabulary.py` fails the build if a level restates its section -root or builds a path by hand. - -Absolute paths, `..` segments and symlinked parents are rejected at -declaration or resolution time by `paths.resolve_within()`, which is the single -implementation shared by fixtures, requirements, start directories and -solutions. - -Need the workspace root itself, above your own section? Say so explicitly: - -```python -from shellgame.paths import WORKSPACE_ROOT - -start_directory = WORKSPACE_ROOT -``` - -Do **not** use `""` for that. `""` means the section root. - ---- - -## 3. Building the workspace: `WorkspaceFixture` - -```python -from shellgame.levels.fixture import DirectoryFixture - -fixture = WorkspaceFixture( - directories=("logs",), - directory_fixtures=(DirectoryFixture("private", mode=0o750),), - files=( - FileFixture("logs/app.log", content="log1"), - FileFixture("private/config", content="secret"), - ), - clean=("logs/archive",), -) -``` - -`clean` runs before the fixture is applied, so a level is restored to a known -state no matter what the previous attempt left behind. Fixtures are idempotent -and will overwrite a file even if the player removed write permission - section -8 teaches `chmod`, and `shellgame reset` has to work afterwards. - -Fixtures also repair file/directory mixups at declared paths: a directory where -a file belongs is replaced, and a file blocking a declared directory (including -a parent directory) is removed before rebuilding it. `overwrite=False` -preserves existing **regular files**, not an incorrect type. Repair never -replaces the fixture root or follows a symlink; file fixtures cannot name `""` -or `"."`. Repair recursively restores traverse and write permissions so cleanup -succeeds even after `chmod 000` or read-only directory mistakes. - -Use `directory_fixtures` with `DirectoryFixture(path, mode)` when a directory -must start with an exact mode. These declarations safely replace files or -symlinks at every declared path component without following the symlink; the -symlink target remains untouched. Like file fixtures, they cannot target the -fixture root itself. `WorkspaceFixture` creates all directories -and child files first, then applies exact directory modes, because creating a -child may temporarily restore its parent's traverse/write bits. Both fixture -and mode paths use the same section-root-relative vocabulary. Modes range from -`0o0000` through `0o7777`, including special permission bits. - -For discovery tasks, verify fixtures using the command the student is taught. -Magic bytes alone may not produce an image recognized by `file`; distinguish -plain text from scripts whose descriptions also contain `ASCII text`. -Counting fixtures should reset away extra entries that would change the answer. - -Put shared scaffolding on the section (`Section(..., fixture=...)`) and level -scaffolding on the level. Both are applied by `Level.prepare()`, in that order. - -Write a custom `setup()` only when the structure cannot be enumerated - the -1.7 maze and the 200-line log in 5.4 are the two justified cases. - ---- - -## 4. Deciding when a level is solved: `Completion` - -```python -completion = Completion( - answer=ExactAnswer("delta"), - requirements=(AtDirectory("delta"), Evidence("pwd_used")), - allow_empty=False, - allow_empty_when=AtDirectory("delta"), -) -``` - -- `answer` grades what the player typed. One rule, optionally a `TupleAnswer` - of several. -- `requirements` are **unconditional**. They are checked whether or not the - answer was correct, and a correct answer never bypasses them. Filesystem - state, working directory, permissions and evidence markers all belong here. -- `allow_empty` permits a bare `shellgame submit`. -- `allow_empty_when` permits it only in a given situation. - -### Answer rules - -`ExactAnswer`, `IntegerAnswer`, `IntegerRangeAnswer`, `ChoiceAnswer`, -`SuffixAnswer`, `OrderedListAnswer`, `TupleAnswer`. - -They share one feedback contract: - -- `error_message=None` → the terse `Messages.INCORRECT`. -- `error_message="…"` → your text. -- `mistakes={...}` → a targeted reply for a specific wrong answer, which is - where teaching actually happens. - -A rejection must never contain the answer, including targeted `mistakes` -feedback and failed navigation requirements. `test_level_invariants.py` and -`test_hint_quality.py` enforce this; a player who guesses once should not be -handed the solution. Discovery hints should explain how to derive the answer, -not state it. Command examples for explicitly assigned filesystem tasks are -still useful. - -`required_message` is what an author sees when the player submits nothing. Set -it whenever a level needs an argument, and include the example command - never -let a `None` answer reach the player as text. - -### Requirements - -`AtDirectory`, `AtHome`, `Evidence`, `FileExists`, `DirectoryExists`, -`TextFileContent`, `FileLineCount`, `PathsMatch`, `PathMoved`, -`PermissionBits`, `PermissionMode`, `DirectoryPermissionMode`. - -`FileExists` and `DirectoryExists` with `should_exist=False` require that no entry -(file, directory, or symlink) remains at the path. `PermissionMode` checks an -exact regular-file mode; `DirectoryPermissionMode` is its directory-only -counterpart. Both validate paths relative to the section root and compare all -permission and special bits (`0o7777`). A directory-mode requirement is a -filesystem requirement, so the level must also declare a `Solution`; use the -existing `Chmod` step when appropriate. - -Navigation levels must use `AtDirectory` with a section-root-relative path. -Checking `Path.cwd().name` would accept a same-named directory elsewhere on the -machine. Its default failure points back to the task and `pwd`, without naming -the expected directory. Custom feedback must not reveal a discovery target. - -Navigation commands in instructions and hints are relative to the player's -declared start directory, not the workspace root. Do not tell a player already -in `level-1` to run `cd level-1/...`, or hard-code a username in an absolute -path example. For a `cd -` exercise, spell out the intermediate moves so the -previous directory is predictable. - -When a command prints a path as the answer, either state which directory that -path is relative to or accept the equivalent output produced after reasonable -navigation within the level. - -### Shell-specific lessons - -Mark Bash-specific syntax explicitly in both the section intro and the affected -level's instructions. Do not imply that Fish implements Bash's `?` or `[...]` -globs. For filesystem-only exercises, provide a quoted `bash -c 'command'` -example so a Fish player can run that command in Bash and submit from the -existing wrapped game shell. Do not tell players to enter an unwrapped -interactive Bash: it would lose the game's shell hooks. - -Only require Bash where the syntax needs it; ordinary `*` globs and quoted -patterns interpreted by external commands such as `find -name` remain portable. -Replay displayed commands from the declared start directory, fresh and after -reset, and require a zero exit status as well as successful completion. -Use explicit `chmod` targets (`u+x`, `a-w`) when an exercise requires a specific -set of users; omitting the target makes symbolic modes depend on `umask`. - ---- - -## 5. Restricting movement: `cd_policy` - -Some navigation levels grade *which command* was used, not just where the -player ended up. Declare that; do not write a hook. - -```python -cd_policy = CdPolicy( - scope=(FromDirectory("gamma/deep/a/b/c"),), - rules=( - RequireEvidence(MarkerManager.PWD_USED, "Nejdřív použijte příkaz 'pwd'."), - RequireExactCommand("../../../..", "Použijte jeden příkaz 'cd ../../../..'."), - ), -) -completion = Completion( - requirements=( - AtDirectory("gamma"), - CdEvidence("Nejdřív použijte `pwd`, přejděte do `c` a vraťte se jedním příkazem."), - ), -) -``` - -- **scope** decides whether the policy applies at all. A move outside the scope - passes silently. -- **rules** are checked in order; the first failure rejects the move with its - message. -- The evidence marker is derived from the level ID (`cd-1.12`), so it is unique - by construction. `CdEvidence(message)` in the completion refers to it without - naming it, and `message` is what the player sees if they submit without having - performed the drill. - -Available scopes: `FromDirectory`, `WithTarget`. -Available rules: `RequireExactCommand`, `RequireAbsolutePath`, -`RequireSourceDirectory`, `RequireEvidence`. - -### Why not a hook? - -The engine performs the anti-soft-lock contract for you: it consults -`cd_enforcement_lifted()` before every rejection, so a player is always allowed -back to the level start directory, and all restrictions end once the marker is -written. Rejections always route through `block_cd()`, which appends the -`shellgame reset` recovery tip. - -A strict level that forgets any of this can leave a player in a directory where -every permitted move leads further away, with no in-game way back. That is why -`tests/test_cd_hooks_no_softlock.py` fails the build when a level can reject a -move without soft-lock coverage. - -Write `_handle_cd` only for genuinely stateful grading - level 1.9, which tracks -a step-by-step walk from `/` to `$HOME`, is the one remaining case. - ---- - -## 6. Proving the level can be finished: `Solution` - -Every other test proves a level rejects a wrong answer. A `Solution` proves it -accepts a right one. - -```python -solution = Solution(steps=(RunShell("cp dulezite.txt dulezite.bak"),), answer="dulezite.bak") -``` - -`tests/test_solutions.py` prepares a throwaway workspace, replays the steps and -asserts `validate()` succeeds - then does it again after `reset()`, because a -level also has to survive a second attempt. - -Steps: `Chdir`, `GoHome`, `MakeDirectory`, `WriteFile`, `AppendFile`, -`CopyPath`, `MovePath`, `RemovePath`, `Chmod`, `RecordEvidence`, -`RecordFdEvidence`, `PerformCd`, `WalkHome`, `RunShell`. - -Guidance: - -- Prefer `RunShell` with the command the instructions actually teach. It proves - the taught command produces the graded result. -- Use `PerformCd` for `cd_policy` levels. It drives the real hook, so a policy - that rejects its own intended command fails the build. Faking the marker with - `RecordEvidence` would hide exactly that bug. -- `RecordEvidence` is only for markers Python cannot produce, such as - `pwd_used`, which the shell wrapper writes. - -A solution is optional in general but **required** for levels that can silently -become unwinnable: custom `validate()`, custom `setup()`, a `cd` policy or hook, -an evidence marker, or any filesystem requirement. The gate lives in -`test_risky_levels_declare_a_solution`. - ---- - -## 7. Adding a section - -1. Create `src/shellgame/levels/sections/sectionN.py`: - - ```python - section = Section(N, root="level-N") - ``` - -2. Add the intro as level `.0`: - - ```python - @section.level(0) - class SectionIntroLevel(Level): - is_intro = True - title = "Sekce N: …" - instructions_file = "sectionN_intro.md" - success_message = "Jdeme na to!" - ``` - - Intro markdown lives in `levels/content/`. A line containing only `---` - starts a new page; the player advances with - `Stiskněte Enter pro pokračování...`. - Intros auto-continue on Enter — do not tell the player to run - `shellgame submit` here. That command is only for real levels. - -3. Add levels with `@section.level(1)`, `@section.level(2)`, … - -There is no registration step and no `get_levels()` trailer: the loader reads -the module-level `section` object. - ---- - -## 8. Custom `validate()` - -Reach for it only when the outcome cannot be expressed declaratively - four -levels out of 91 qualify. When you do: - -- Call `super().validate(...)` first and return early on failure, so the - declarative rules still run. -- Return `(bool, str)`; the string is shown to the player. -- Never emit a message containing `None`. -- Declare a `Solution`; the gate requires one. - ---- - -## 9. Quality gates - -``` -make test # pytest with coverage -make lint # ruff + mypy -make format-check # ruff format -``` - -Structural invariants that will fail a careless level, and what each protects: - -| Test | Protects | -|------|----------| -| `test_path_vocabulary.py` | no restated section roots, no hand-built paths | -| `test_marker_invariants.py` | every marker is owned and cleared by exactly one level | -| `test_cd_hooks_no_softlock.py` | no strict level can strand a player | -| `test_solutions.py` | risky levels stay winnable, before and after reset | -| `test_level_invariants.py` | start directories exist; defaults never reveal answers | -| `test_hint_quality.py` | assessment/discovery copy never hands over the answer | - -If one of these fails, the invariant is usually right. Fix the level. diff --git a/docs/protocols/SHELL_PROTOCOL.md b/docs/protocols/SHELL_PROTOCOL.md deleted file mode 100644 index 3e996bb..0000000 --- a/docs/protocols/SHELL_PROTOCOL.md +++ /dev/null @@ -1,79 +0,0 @@ -# Shell Command Protocol - -ShellGame uses a small line protocol to update the wrapped shell after a Python -command exits. Protocol directives are written to stderr; normal stderr is -preserved and shown to the player. - -## Format - -```text -__SHELLGAME_EXEC__v1 <verb> [base64-argument...] -``` - -Arguments are UTF-8 strings encoded with standard base64. This avoids shell -word splitting and preserves spaces and shell metacharacters without `eval`. - -| Verb | Arguments | Effect | -| --- | --- | --- | -| `cd` | destination | Change the wrapper's directory with `builtin cd` | -| `export` | variable name, value | Set and export one environment variable | -| `echo` | message | Print one message | -| `pwd` | none | Print the wrapper's working directory | -| `exit` | none | Exit the wrapped subshell | - -Unknown versions, verbs, or argument counts are ignored. Environment variable -names must match `[A-Za-z_][A-Za-z0-9_]*`. - -## Python side - -`src/shellgame/shell/client.py` validates verbs, encodes each argument, and -emits the directive. Callers use `ShellClient`; they do not build protocol -lines manually. - -## Shell side - -The bash and fish templates: - -1. capture command stderr in a temporary file, -2. recognize only lines beginning with `__SHELLGAME_EXEC__`, -3. split the protocol header and encoded tokens, -4. decode arguments, -5. dispatch a fixed verb implementation, -6. print all non-protocol stderr unchanged. - -There is no arbitrary-command fallback. - -## Wrapper launch - -### Bash - -```text -bash --rcfile <integration_script> -i -``` - -`--norc` must not be used because it disables the selected rcfile. - -### Fish - -```text -fish --init-command "function fish_greeting; end; source <integration_script>" -``` - -Autostart remains at the end of each template after the `shellgame` function -has been defined. - -## Remove handling - -`shellgame remove` is handled specially by the wrapper. After successful -removal it exits directly, avoiding cwd errors when the workspace being -removed is the current directory. - -## Extending the protocol - -Adding a verb expands the shell-facing security boundary. New verbs must: - -- have fixed arity, -- validate arguments in Python and shell code, -- avoid generic evaluation, -- be implemented for both bash and fish, -- include runtime parity tests. diff --git a/docs/spec.md b/docs/spec.md deleted file mode 100644 index 753bc8f..0000000 --- a/docs/spec.md +++ /dev/null @@ -1,301 +0,0 @@ -# ShellGame – Complete Design & Level Specification - -## 0. Purpose -ShellGame is a local, single-binary, Linux terminal learning experience for first-semester CS students. It focuses on interactive mastery of filesystem navigation and basic shell operations—especially fluent use of `cd`, path reasoning (absolute vs relative), and fundamental file manipulation. The design targets: -- Average completion time: ~75 minutes -- Slow path (essential core): ~20 minutes (Sections 1–3) -- Optional advanced / bonus: up to ~90 minutes - ---- - -## 1. High-Level Features - -### 1.1 Deployment -- Single static binary named `shellgame` in `PATH` -- User state stored at: `~/.config/shellgame/state.json` -- Workspace per user at: `/tmp/shellgame-$USER/` -- Temporary directories automatically cleared by system on reboot (no cleanup service required) - -### 1.2 Core Commands -| Command | Purpose | -|---------|---------| -| `shellgame init` | Compatibility command; initialization normally happens automatically | -| `shellgame` | Show current level instructions (or tutorial splash) | -| `shellgame hint` | Show progressive hints (max 3 per level) | -| `shellgame submit ANSWER` | Submit an answer derived from navigation / file inspection / creation | -| `shellgame submit` | For state-based validations (location, existence of files) | -| `shellgame reset` | Rebuild current level's directory structure | -| `shellgame remove` | Delete state and workspace | -| `shellgame status` | Show progress, time per level, hints used | -| `shellgame hint --repeat` | Reprint already revealed hints without consuming a new one | -| `shellgame skip` | Advance past a bonus (optional or extension) level | -| `shellgame levels` | List every level with its number and name | -| `shellgame resume LEVEL` | Jump to `LEVEL` (e.g. `1.4`) and save it as the current position | -| `shellgame resume` | With no level, lists the levels to choose from | -| `shellgame repeat` | Re-show a level or a section intro | -| `shellgame show --level` / `--section` | Re-show the current level or the current section intro | -| `shellgame exit` | Leave the ShellGame subshell | - -### 1.3 Game Philosophy -Answers (formerly “flags”) are natural discoveries: directory names, file contents, counts, sizes—never artificial tokens. Reinforces authentic terminal literacy. - ---- - -## 2. State & Data - -### 2.1 State File Structure (example) -```json -{ - "username": "jdupak-ms", - "workspace": "/tmp/shellgame-jdupak-ms", - "current_level": "2.3", - "start_time": "2025-11-14T09:00:00Z", - "levels_complete": { - "1.1": {"time_sec": 45, "hints": 0, "attempts": 1}, - "1.2": {"time_sec": 60, "hints": 1, "attempts": 2} - } -} -``` - -### 2.2 Workspace Example Layout -``` -$SHELLGAME_WORKSPACE/ (default: /tmp/shellgame-$USER) -├── level-1/ section root for section 1 -│ ├── alpha/ -│ ├── gamma/deep/a/b/c/ -│ └── maze/ -├── level-2/ -├── ... -└── level-11/ -``` - -One directory per section, named `level-<N>`, declared once as the section root. -Every path a level declares is relative to that directory - see -[AUTHORING.md](AUTHORING.md). - ---- - -## 3. Tutorial & Pedagogy - -Each section starts with: -- Concept explanation -- Mental model (tree, addresses, relative vs absolute) -- Commands introduced (with short syntax examples) -- Then sublevels for practice -- Hints escalate: orientation → specific command → near-answer clue - ---- - -## 4. Navigation Bootcamp (Deep Focus on `cd`) - -This bootcamp can exist as: -- A dedicated early mega-section OR -- Interwoven as expanded Section 1 + part of Section 2 - -### Learning Outcomes -Students can: -- Identify current directory (`pwd`) -- Move with absolute paths from anywhere -- Traverse using relative chains (`../..`, siblings) -- Reset via `~` (home) or `/` (root) -- Use `cd -` to toggle -- Understand `.` vs `..` -- Build complex relative path sequences consciously - -### Sublevels - -| ID | Focus | Task Summary | Expected Answer Type | -|-----|-------|--------------|----------------------| -| 1.1 | `pwd` | Report last directory name | `intro` | -| 1.2 | Home | Longest hidden dir in `~` | e.g. `.config` | -| 1.3 | Root | Count entries in `/` | Integer (e.g. `13`) | -| 1.4 | Absolute | Reach `/tmp/.../absolute/alpha/` and read file | `sample.txt` | -| 1.5 | Up one | `cd ..` basename after move | `absolute` | -| 1.6 | Multi-up | From deep chain go up 3 levels | `r1` | -| 1.7 | Sibling | Move to `notes/` via relative path | File inside (e.g. `reminder.md`) | -| 1.8 | Dot | `cd .` effect? yes/no | `no` | -| 1.9 | Chain | Compose `../../nextstage/step1` path | `complete.done` | -| 1.10 | Abs vs Rel | Contrast `cd data` vs absolute path | `alpha,data` | -| 1.11 | Home + Root | First letters of dirs reached | `h,r` | -| 1.12 | `cd -` | Toggle prior directory | `maze` | -| 1.13 | Maze | Follow sequence to endpoint | basename (e.g. `c`) | -| 1.14 | Tab (optional) | Long directory name; give 5th char | single character | -| Walk Root→Home | Drill | Stepwise journey `/` → `/home` → `/home/$USER` | Sequence or final path | -| Reverse Walk | Drill | From home up to `/`, then back down | Path articulation | -| Breadcrumb Reflection | Meta | List all dirs traversed | Ordered list | - ---- - -## 5. Full Section Breakdown (Updated & Re-ordered) - -> **Stale.** This breakdown is the original design intent and has drifted from the -> shipped game (it still lists bonus sections and extension levels that were never -> built). The shipped order is Navigation, Files, Hidden, Create/Delete, Inspect, -> Copy/Move, **Wildcards**, **Permissions**, **Redirection & Pipes**, **Error -> streams**, Search. The authoritative source is -> `src/shellgame/levels/sections/`; `docs/AUTHORING.md` describes how to change it. - -### Section 1: Where Am I? (7 min) -Tools: `pwd`, `ls`, `cd`, `cd ~` -Sublevels: Find current directory, list contents, enter a directory, go home, use an absolute path. - -### Section 2: Moving Around (7 min) -Tools: `cd ..`, `cd -`, relative chains -Sublevels: Parent directory recognition, multi-level ascent, **previous directory toggling with `cd -`**, relative maze navigation. - -### Section 3: Hidden and Visible (6 min) -Tools: `ls -a`, `cat` -Sublevels: Find hidden files, read hidden files, count hidden directories. - -### Section 4: Creating Your World (12 min) -Tools: `touch`, `mkdir`, `mkdir -p`, `rm`, `rmdir` -Sublevels: Create files and directories, create nested structures, remove files, attempt to remove non-empty directories. - -### Section 5: File Detective (8 min) -Tools: `ls -l`, `file` -Sublevels: Identify files by size, detect file types, find disguised files. - -### Section 6: Copying & Moving (10 min) -Tools: `cp`, `cp -r`, `mv` -Sublevels: Copy files, copy directories recursively, rename files, move files between directories. - -### Section 7: Permissions Matter (7 min) -Tools: `ls -l`, `chmod` -Sublevels: Find executable files, make a script executable, remove write permissions, apply numeric modes. - -### Section 8: Content Control & Redirection (10 min) -Tools: `echo >`, `>>`, `head`, `tail`, `|`, `grep`, `wc`, `sort` -Sublevels: Create files with content, append to files, view first/last lines, pipe commands, filter with `grep`, count with `wc`. - -### Section 9: Wildcards & Patterns (8 min) -Tools: `*`, `?`, `[]` -Sublevels: Use wildcards to count, copy, move, and remove multiple files at once. - -### Bonus Section 10: Search & Destroy (10 min) -Tools: `find`, `grep -r`, `grep -i` -Sublevels: Find files by name/type/size, search content recursively, use `-exec`. - -### Bonus Section 11: Tab Completion Master (5 min) -Tools: Tab completion (implicit) -Sublevels: Character extraction from long names, handle ambiguous prefixes. - ---- - -## 6. Answer Style Guidelines - -Answer Types: -1. Directory basename (`alpha`, `workspace`) -2. File basename without extension (`roadmap`, `important`) -3. Single word from file content (`Tuesday`, `gold`) -4. Integer counts (`5`, `1337`, `30`) -5. Permission fragment (`rwx`, `r-x`) -6. Character extraction (`l`, `e`) -7. Ordered pair/list (`alpha,data`, `h,r`) - -Validation Considerations: -- Trim whitespace -- Case-insensitive for content words, case-sensitive for filenames -- Numeric answers validated as integers -- Provide gentle mismatch feedback. - ---- - -## 7. Hint System - -Example Hint Ladder (Level 2.2 Multi-Up): -1. “You can go up one level with `cd ..`.” -2. “Chain them: `cd ../../..` moves up three at once.” -3. “From your current depth, `cd ../../..` should land you in `r1`.” - ---- - -## 8. Example Instruction Screen - -``` -══════════════════════════════════════════════════════ -LEVEL 2.4: Relative Maze Navigation - -You are inside the maze directory. Each room uses compass -direction names. Your goal: reach the room containing the file -treasure.dat and read its first word. - -Tools you need: -- cd (relative navigation only) -- ls (see available directions) -- cat (read the file when found) - -Task: -1. Explore using ls -2. Move between rooms using cd <direction> -3. Locate treasure.dat -4. Read its first word and submit that word. - -Submit with: shellgame submit <word> -Need help? Type: shellgame hint -══════════════════════════════════════════════════════ -``` - ---- - -## 9. Directory Structure Examples for Navigation Tasks - -Navigation levels build their own structure from a declarative -`WorkspaceFixture`, so the layout lives next to the level rather than in this -document. Section 1 is the reference example: - -``` -level-1/ -├── alpha/ 1.3 enter a directory, read what is inside -├── delta/ 1.2 pattern matching with `ls` -├── gamma/deep/a/b/c/ 1.6 multi-level ascent with one `cd ../../..` -├── absolute-target/ 1.8 jump by absolute path -└── maze/00 .. maze/04 1.7 follow the GO_* markers to 04/final -``` - ---- - -## 10. Progress & Feedback Examples - -### Success -``` -✅ Correct! -You navigated from a deep path up three levels efficiently. -Key idea: Chaining ../ segments accelerates movement. -Time: 62s | Hints used: 1 -``` - -### Failure -``` -✗ Not quite. -You submitted: 'absolte' -No directory with that exact name. -Check spelling and verify with: ls -Try again or use: shellgame hint -``` - ---- - -## 11. Future Extensions (Not in Current Lab) -- Advanced redirection (`2>`, `&>`, `/dev/null`) -- Editing tools (nano/vim) — intentionally excluded now -- Environment awareness (`$HOME`, `$PWD`) — conceptual only -- Scripting mode (loops, variables) — out of scope - ---- - -## 13. Summary Table (Condensed & Updated View) -<!-- REVISED TABLE (Aligned to ~60 min average path) --> -| Section | Focus | Avg Time (Core) | Core Levels (slow path) | Extension / Optional | -|---------|-------|-----------------|-------------------------|----------------------| -| 1 | Basic Location & Absolute | 10m | 1.1–1.7 | 1.8–1.10 / 1.11 | -| 2 | Relative Mastery + Simple Read | 7m | 2.1–2.4 | 2.5 (Ext) / 2.6 | -| 3 | Hidden Basics | 5m | 3.1–3.2 | 3.3 (Ext) / 3.4 | -| 4 | Creation & Cleanup | 9m | 4.1–4.5 | 4.6 (Ext) / 4.7 | -| 5 | File Inspection | 6m | 5.1–5.3 | 5.4 (Ext) / 5.5 | -| 6 | Copy vs Move | 8m | 6.1–6.4 | 6.5 (Ext) / 6.6 | -| 7 | Permissions Basics | 6m | 7.1–7.2,7.4 | 7.3 (Ext) | -| 8 | (Future Content & Redirection) | (TBD) | - | - | -| 9 | Stderr & Redirection | 7m | 9.1–9.4 | 9.5 (Ext) / 9.6 | -| 10 | Wildcards | 7m | 10.1–10.4 | 10.5–10.6 (Ext) / 10.7 | -| 11 | Search & Discovery | 9m | 11.1–11.4 | 11.5–11.6 (Ext) / 11.7 | -> Advancement Logic: Progression now triggers after each core block; extensions never gate advancement. \ No newline at end of file diff --git a/docs/specs/sections/section1.md b/docs/specs/sections/section1.md deleted file mode 100644 index 96756d9..0000000 --- a/docs/specs/sections/section1.md +++ /dev/null @@ -1,368 +0,0 @@ -# Section 1 Specification – "Where Am I?" - -## 1. Purpose & Scope -Section 1 now focuses even more intensely on raw navigation fluency: -- Repeated use of `pwd` after movement. -- Conscious directory listing with `ls`. -- Relative movement (`cd name`, `cd ..`, multi-level ascent). -- Maze following + instruction parsing. -- Absolute path construction from root (before any home-specific tasks). -- Manual root→home walk (stepwise, verified). -- Hidden entries only near the end. -- Optional structural visualization with `tree` as last level. - -Deliberate exclusions in this section: -- No file content reading via `cat`, `head`, etc. (Only names, counts, and path reasoning.) -- No assumptions about home being `/home/<user>`; we rely on `$HOME`. -- No symlinks, permissions, creation, deletion. - -Estimated Time: 12–15 minutes (core Levels 1.1–1.10) + optional Level 1.11 (~2–3 minutes). - -<!-- REVISED TIME & PEDAGOGY --> -> Time Calibration: Target 10 minutes average; slow path 8 minutes using Levels 1.1–1.7 (core). Levels 1.8–1.10 become “Extension” and 1.11 remains “Optional Visualization”. -> Core Levels (advancement-critical): 1.1–1.7 -> Extension: 1.8–1.10 -> Optional: 1.11 -> Hint Model (all sections unified): 1) Orient (command recall) 2) Strategy (how / pattern) 3) Reveal (answer or near-answer). - -## 2. Learning Objectives -By the end of Section 1 the player will: -1. Define absolute vs relative path (rooted at `/` vs anchored at current directory). -2. Use `pwd` reflexively for confirmation. -3. Apply `ls` to enumerate and select targets. -4. Use `cd ..` and multi-up patterns (`cd ../../..`) confidently (introduced gradually). -5. Traverse a directive-driven maze requiring up, across, and ignoring stale directions. -6. Jump directly to a deep target using an absolute path. -7. Perform a manual root→home reconstruction using only relative steps. -8. Identify their actual home path without assuming conventional prefix. -9. Interpret file extensions conceptually (e.g., `.txt`) and submit basenames correctly. -10. (Optional) Visualize structure using `tree` to reinforce mental model. - -## 3. Concept Tutorial (Displayed Before Level 1.1) -Core concepts: -- Filesystem as a tree with `/` at the top. -- Working directory (CWD) defines relative path origin. -- Commands in play: `pwd`, `ls`, `cd`, `cd ..`, `cd ~`, and optionally `tree` (only final level). -- `$HOME` is authoritative for your home; do not guess its parent path. -- Extensions: a filename may have a suffix after a dot (`inside.txt` has basename `inside` and extension `txt`). We only strip the final extension part; no nested extension complexity here. - -Short Prompt: -"Master moving deliberately. Confirm with pwd; inspect with ls; move with cd (including cd ..). Absolute paths start at `/`. Your home is reached via `cd ~` or `cd $HOME`—do not assume its parent directory name." - -## 4. Directory Layout (Initial for Section 1) -Workspace subset: `$WORKSPACE/level-1/` - -``` -level-1/ -├── alpha/ -│ └── inside.txt (basename: inside) -├── delta/ -│ └── single.dat (basename: single) -├── gamma/ -├── patterns/ -│ ├── data.txt -│ ├── dog.md -│ ├── drama.log -│ ├── zebra.txt -│ └── omega/ -├── absolute-target/ -│ └── PLACEHOLDER.answer (contains no read task; answer is directory basename later) -└── maze/ - ├── 00/ - │ ├── _GO_TO_DIR_01 - │ ├── 02/ - │ │ └── YOU_ARE_NOT_SUPPOSED_TO_BE_HERE - │ ├── 03/ - │ │ └── YOU_ARE_NOT_SUPPOSED_TO_BE_HERE - │ └── 04/ - │ └── YOU_ARE_NOT_SUPPOSED_TO_BE_HERE - ├── 01/ - │ ├── _GO_TO_DIR_deep - │ ├── shallow/ - │ │ └── YOU_ARE_NOT_SUPPOSED_TO_BE_HERE - │ ├── surface/ - │ │ └── YOU_ARE_NOT_SUPPOSED_TO_BE_HERE - │ └── deep/ - │ ├── _GO_TO_DIR_a - │ ├── c/ - │ │ └── YOU_ARE_NOT_SUPPOSED_TO_BE_HERE - │ ├── d/ - │ │ └── YOU_ARE_NOT_SUPPOSED_TO_BE_HERE - │ └── a/ - │ ├── _GO_TO_DIR_b - │ ├── x/ - │ │ └── YOU_ARE_NOT_SUPPOSED_TO_BE_HERE - │ ├── z/ - │ │ └── YOU_ARE_NOT_SUPPOSED_TO_BE_HERE - │ └── b/ - │ ├── _GO_UP_2_THEN_GO_TO_02 - │ ├── decoy.txt - │ └── note.md - ├── 02/ - │ ├── _GO_UP_1_THEN_GO_TO_03 - │ ├── stray.txt - │ └── readme.md - ├── 03/ - │ ├── _GO_TO_DIR_x - │ ├── w/ - │ │ └── YOU_ARE_NOT_SUPPOSED_TO_BE_HERE - │ ├── z/ - │ │ └── YOU_ARE_NOT_SUPPOSED_TO_BE_HERE - │ └── x/ - │ ├── _GO_TO_DIR_y - │ ├── q/ - │ │ └── YOU_ARE_NOT_SUPPOSED_TO_BE_HERE - │ ├── r/ - │ │ └── YOU_ARE_NOT_SUPPOSED_TO_BE_HERE - │ └── y/ - │ ├── _GO_UP_1_THEN_GO_TO_04 - │ └── marker.txt - ├── 04/ - │ ├── _GO_TO_DIR_final - │ ├── finish/ - │ │ └── YOU_ARE_NOT_SUPPOSED_TO_BE_HERE - │ ├── end/ - │ │ └── YOU_ARE_NOT_SUPPOSED_TO_BE_HERE - │ └── done/ - │ └── YOU_ARE_NOT_SUPPOSED_TO_BE_HERE - └── final/ - └── VICTORY.marker - - -``` - -Maze semantics: -- Every directory contains exactly one instruction file starting with underscore that guides the next move. -- Instruction files are either `_GO_TO_DIR_<NAME>` (descend into child dir <NAME>) or `_GO_UP_<N>_THEN_GO_TO_<NAME>` (ascend N levels, then enter named sibling <NAME>). -- Sequence: 00 → 01 → deep → a → b → (up 2 to 02) → 02 → (up 1 to 03) → 03 → x → y → (up 1 to 04) → 04 → final. -- Depth points: 01/deep/a/b and 03/x/y provide ≥4-level depth enforcing multi-level mental modeling. -- Answer = basename of the final directory reached (`final`). - -Home (`$HOME`) environment is external; we do not assume location pattern. Hidden directory count appears late (Level 1.10). - -## 5. Level Index (Expanded) -| ID | Title | Focus | Answer Type | -|-------|-----------------------------------------|--------------------------------------|--------------------------| -| 1.1 | Current Location | pwd | Directory basename | -| 1.2 | Listing & Pattern Recognition | ls pattern picking | Directory basename | -| 1.3 | Enter & Report (Extension Concept) | cd + basename (strip extension) | File basename | -| 1.4 | Return to Base | cd .. single ascend | Directory basename | -| 1.5 | Multi-Level Ascent | cd ../../.. style | Directory basename | -| 1.6 | Maze Navigation | Follow instruction chain + ups | Directory basename | -| 1.7 | Absolute Path Jump | Construct & use absolute path | Directory basename | -| 1.8 | Root to Home Walk (Manual) | Extension (skip for slow core) | Ordered list (comma) | -| 1.9 | Home Confirmation | Extension | Directory basename | -| 1.10 | (Optional) Visualizing Structure | Optional (moved) | Ordered list | - -<!-- NOTE: Renamed classification: 1.8–1.9 Extension; 1.10 Optional --> - -## 6. Detailed Level Specs - -### Level 1.1 – Current Location -Instruction: "Report the basename of your current directory." -Actions: `pwd`, parse last segment. -Answer: `level-1` -Hints: -1. "Use pwd." -2. "Last part after final slash." -3. "Answer: level-1" -Failure: Full path supplied → ask for basename only; case mismatch → emphasize sensitivity. - -### Level 1.2 – Listing & Pattern Recognition -Instruction: "List entries. Find directory starting with 'd' and ending with 'a'." -Answer: `delta` -Validation ensures directory exists and matches pattern. -Failure messages differentiate file vs directory. -Hints escalate from using `ls` to pattern explanation to explicit answer. - -### Level 1.3 – Enter & Report (Extension Concept) -Instruction: "Enter 'alpha' and submit the name of the single file inside without its extension." -Directory: `alpha/inside.txt` -Extension Teaching: -- Explain: Everything after the last '.' is extension. -- Basename is before that part. -Special Failure Mode: -- If user submits `inside.txt`: respond with "You included the extension. 'inside.txt' has extension 'txt'. Submit only 'inside'." -Answer: `inside` -Hints: -1. "cd alpha" -2. "List contents; strip '.txt'." -3. "Answer: inside" -No `cat` usage required—listing is enough. - -Post-Completion Requirement: -Force manual navigation back to `level-1` (transition to Level 1.4). Validation for 1.4 checks current directory before accepting answer. - -### Level 1.4 – Return to Base -Start: Inside `alpha/` -Instruction: "Move up to the parent directory and submit its basename." -Action: `cd ..` -Answer: `level-1` -Validation: Must actually be in parent; can compare `pwd` ending. -Failure: If still in `alpha`, instruct to use `cd ..`. -Hints: -1. "Use cd .. to go up one level." -2. "After moving, run pwd to confirm." -3. "Parent is level-1" - -### Level 1.5 – Multi-Level Ascent -Setup: Player is placed (or directed) into a deeper chain `delta/single.dat` not entered (we will create subdirs for ascent demo): -Add temporary path for this level: -``` -level-1/gamma/deep/a/b/c/ -``` -Start: `level-1/gamma/deep/a/b/c/` -Instruction: "Go up 3 levels in a single command and submit the basename of the directory you land in." -Action: `cd ../../..` -Target: `deep` -Answer: `deep` -Validation: Confirm `pwd` ends with `/deep` -Hints ladder introduces chaining `..` before giving explicit command. -Failure: Using multiple sequential single ascents acceptable but emphasize efficiency. - -### Level 1.6 – Maze Navigation -Start: `level-1/maze/entry/` -Goal: Follow `GO_` instructions; handle multi-up directives; disregard stray files and traps. -Answer: `sanctuary` (ending directory containing `VICTORY.marker`) -Validation: Confirm current directory is `maze/labyrinth/corridor/shrine/sanctuary` -Hints: -1. "Only follow filenames starting with GO_." -2. "For multi-up, count how many ../ segments you need." -3. "Final directory basename is 'final'" -Failure: If answer given without being in `final`: "You are not at the final directory; verify with pwd." - -### Level 1.7 – Absolute Path Jump -Instruction: "From anywhere now, use a single absolute cd to reach `level-1/absolute-target/` and submit its basename." -Answer: `absolute-target` -Note: No file reading. Directory basename is the answer. -Validation: Must be located at that path. -Hints: -1. "Absolute path starts with /" -2. "Include /tmp/.../level-1/absolute-target in one cd" -3. "Answer: absolute-target" - -### Level 1.8 – Root to Home Walk (Manual) -Instruction: "Walk to root '/', then step by step to your home ($HOME) using only relative moves. Submit comma-separated list of each basename you entered AFTER leaving root (excluding the empty root)." -Process: -1. `cd /` -2. Identify first segment of `$HOME` path by comparing `$HOME` and `pwd`. -3. Enter segments one at a time: `cd <segment>` -Answer: Ordered list of path segments constructing home (e.g. if `$HOME=/net/students/username` → `net,students,username`) -Validation: -- Must match actual `$HOME` expansion. -Hints: -1. "Find $HOME with echo $HOME." -2. "Break it into components after leading slash." -3. "Comma list of each component in order." - -### Level 1.9 – Home Confirmation -Instruction: "Navigate (or remain) at $HOME and submit the basename of your home directory." -Answer: Last component of `$HOME` path (`username`). -Validation: Verify `pwd == $HOME`. -Failure: Not at home → instruct to `cd $HOME`. -Hints escalate from using `$HOME` to final component reveal. - -### Level 1.10 – (Optional) Visualizing Structure -Instruction: "In `level-1/` list (or visualize using tree) top-level directory names alphabetically, submit comma-separated list." -Directories: `absolute-target, alpha, delta, gamma, maze, patterns` -Answer: `absolute-target,alpha,delta,gamma,maze,patterns` -Hints escalate same pattern; final gives sequence. -Optional metadata: `optional=true`. - -## 7. General Validation Rules -- Trim whitespace. -- Basename answers: no `/`. -- File basename: remove single trailing extension (portion after last dot). -- Ordered list: split by commas; ignore surrounding spaces; order must match expected. -- Integer: numeric only. -- Presence checks rely on current `pwd` for location-sensitive levels (1.4, 1.5, 1.6, 1.7, 1.9, 1.10, 1.11). - -## 8. Hint System Mechanics -Three hints per level: -1. Recall / orientation. -2. Strategy / partial path or method. -3. Explicit near-answer or full answer (allowed in early formative tasks). -Track `hints_used` per level. - -## 9. Telemetry / State Logging -Per completion: -``` -"1.x": { - "time_sec": <int>, - "hints": <int>, - "attempts": <int> -} -``` -Optional level 1.11 recorded only if attempted; otherwise can store `"1.11": {"optional_skipped": true}` or omit. - -Section completion condition: Levels 1.1–1.7 correct (1.8–1.10 Extension, 1.11 Optional). - -## 10. Failure Message Templates -- Extension included: "You included the extension. Submit only the part before the final dot." -- Multi-word where single expected: "Expected a single word; multiple tokens received." -- Not at required directory: "You are currently at a different path—use pwd and adjust." -- Directory vs file mismatch: "Type mismatch: expected a directory." -- Case mismatch: "Case-sensitive mismatch—verify spelling." -- Integer parse: "Expected an integer count." -- Order mismatch in lists: "List must be alphabetically ordered." - -## 11. Edge Cases & Robustness -- Home path unusual (network mount): always derive from `$HOME`; never pattern-match `/home/`. -- If player creates extra hidden directories before Level 1.10, count naturally includes them (teaches environment integrity awareness). -- Maze resilience: ignore unexpected files; only `_GO_` prefixed names instruct. -- Missing `tree`: fallback to `ls` sorted output; instruct: "tree not available; simulate by listing and sorting." - -## 12. Implementation Checklist -- Generate directory structure before starting Section 1. -- Maze instruction files named predictably; ensure multi-up instructions exist. -- Provide helper to compare current path for ascent validation. -- Implement extension strip: `basename.split('.')[:-1].join('.')` (simple; assume only one dot for teaching clarity). -- Home walk segmentation: `segments = HOME_PATH.strip('/').split('/')` -- (Hidden directory logic deferred to Section 3; not implemented here.) - -## 13. Advancement Criteria -Upon finishing Level 1.9: -- `current_level = "2.1"` -- If Level 1.11 later attempted, do not modify advancement; treat as post-completion enrichment. - -## 14. Sample Instruction Screen (Level 1.7) -``` -══════════════════════════════════════════════════════ -LEVEL 1.7: Absolute Path Jump - -Goal: -Reach the directory 'absolute-target' inside level-1 using ONE -absolute cd command starting from wherever you are. - -Remember: -Absolute paths start with /. You can find your current full path -with: pwd - -Task: -1. Determine full path to .../level-1/absolute-target -2. cd /tmp/.../level-1/absolute-target (adjust ... to match your workspace) -3. Submit the basename of that directory. - -Submit with: shellgame submit absolute-target -Need help? Type: shellgame hint -══════════════════════════════════════════════════════ -``` - -## 15. Pedagogical Reinforcement Points -- Habit loop: move → pwd → interpret → next move. -- Multi-up navigation accelerates path correction. -- Maze compels careful reading of names, not guessing. -- Absolute paths free students from local uncertainty. -- Root→home walk consolidates path parsing and relative construction. -- Hidden directories deferred entirely to Section 3. -- Visualization last to transform abstract learning into structural insight. - -## 16. Future Cross-References -- Section 2 deepens relative chains (`../..` plus siblings). -- Section 3 formalizes hidden files & `ls -a` beyond counting. -- Later sections add creation, manipulation, and permissions—kept out here for focus. - -## 17. Summary (Instructor View) -Section 1 now delivers sustained navigation drills: baseline identification, pattern selection, controlled descent/ascent, maze interpretation, absolute path synthesis, and environment awareness without assuming conventional home layout. Students exit with high-confidence directional skills ready for relative chain mastery. - -Addendum (Pacing): Encourage students to advance after 1.7 if time-constrained; revisit extension/optional tasks post-progress without blocking flow. \ No newline at end of file diff --git a/docs/specs/sections/section10.md b/docs/specs/sections/section10.md deleted file mode 100644 index e492410..0000000 --- a/docs/specs/sections/section10.md +++ /dev/null @@ -1,330 +0,0 @@ -# Section 10 Specification – "Error Streams & Advanced Redirection" - -> **Renumbered.** This section used to be number 9; wildcards were moved ahead of -> permissions and redirection so globbing is taught before it is used. Level IDs and -> `level-9/` workspace paths below still use the old `9.x` numbering, and the -> Extension/Optional level statuses were never implemented. Treat this file as the -> original design intent, not as a description of the shipped section. - -## 1. Purpose & Scope -Section 9 introduces stderr (standard error) as a separate output stream and advanced redirection techniques. Building on Section 8's stdout redirection, students learn to: -- Distinguish between stdout (stream 1) and stderr (stream 2) -- Redirect stderr separately with `2>` -- Redirect both streams with `&>` or `2>&1` -- Capture errors while suppressing output -- Debug command failures by examining error messages -- Combine stream redirection with pipes - -Deliberate exclusions: -- No process substitution or advanced file descriptor manipulation -- No exec or custom file descriptors beyond 0, 1, 2 -- No signal handling -- No background processes - -Allowed commands: All previous commands plus stream redirection operators: `2>`, `2>&1`, `&>`, `>/dev/null`, `2>/dev/null` -Estimated Time: 8–10 minutes (core Levels 9.1–9.5) + optional Level 9.6 (~2 minutes) -<!-- REVISED: --> -> Time Calibration: Target 7 minutes average; slow path 6 minutes (Levels 9.1–9.4). Level 9.5 becomes Extension (debug capture), 9.6 Optional (aggregation). - -## 2. Learning Objectives -By the end of Section 9 the player will: -1. Explain the difference between stdout and stderr. -2. Redirect stderr to a file using `2>`. -3. Redirect both stdout and stderr to the same file using `&>` or `> file 2>&1`. -4. Discard output by redirecting to `/dev/null`. -5. Capture error messages from failing commands. -6. Combine stderr redirection with pipes. -7. Debug scripts by examining captured error output. -8. (Optional) Build complex redirection chains separating success and error logs. - -## 3. Concept Tutorial (Displayed Before Level 9.1) -Key concepts: -- **Three standard streams**: - - stdin (0): input - - stdout (1): normal output - - stderr (2): error messages -- **Why separate streams?**: Allows filtering errors independently from results -- **Redirection operators**: - - `>` or `1>`: redirect stdout only - - `2>`: redirect stderr only - - `&>`: redirect both stdout and stderr to same destination - - `2>&1`: redirect stderr to wherever stdout currently goes - - Order matters: `> file 2>&1` (stdout to file, then stderr to stdout's location) -- **/dev/null**: A "black hole" device that discards all data written to it -- **Common patterns**: - - `command 2> errors.log`: save errors, show output - - `command > output.log 2>&1`: save both to same file - - `command 2>/dev/null`: suppress errors only - - `command &>/dev/null`: suppress everything - -Visual example: -``` -ls existing_file missing_file - ↓ -existing_file (stdout) -ls: missing_file: No such file or directory (stderr) - -ls existing_file missing_file 2> errors.txt - ↓ -existing_file (stdout to terminal) -(errors.txt contains: "ls: missing_file: No such file or directory") -``` - -Short prompt: -"Separate success from failure. Capture errors for debugging. Silence noise when needed. Control every stream." - -## 4. Directory Layout (Initial for Section 9) -Base: `$WORKSPACE/level-9/` - -Proposed structure: -``` -level-9/ -├── mixed/ -│ ├── good1.txt -│ ├── good2.txt -│ └── good3.txt -├── commands/ -│ ├── failing_script.sh (executable: exits with error) -│ ├── noisy_process.sh (executable: produces both stdout and stderr) -│ └── silent_worker.sh (executable: only produces stderr on failure) -├── debug/ -│ ├── broken_command.sh (script with intentional error) -│ └── expected_error.log (reference for comparison) -├── logs/ -│ └── .placeholder -└── capture/ - └── .placeholder -``` - -Example script contents: -- `failing_script.sh`: `#!/bin/bash\necho "Starting..."\necho "Error: Something failed" >&2\nexit 1` -- `noisy_process.sh`: `#!/bin/bash\necho "Processing..."\necho "Warning: Low memory" >&2\necho "Done"` - -## 5. Level Index -| ID | Title | Focus | Answer Type | -|------|------------------------------------|----------------------------------------|-----------------------| -| 9.1 | Understanding Stream Separation | Observe stdout vs stderr | Single word | -| 9.2 | Redirecting Stderr | 2> operator | File basename | -| 9.3 | Redirecting Both Streams | &> or 2>&1 | Integer (byte count) | -| 9.4 | Silencing Output | >/dev/null and 2>/dev/null | Single word | -| 9.5 | Capturing Command Errors | Extension | Single word | -| 9.6 | (Optional) Complex Stream Control | Optional | Ordered list | - -## 6. Detailed Level Specifications - -### Level 9.1 – Understanding Stream Separation -Start: `$WORKSPACE/level-9/mixed/` -Task: "Run: ls good1.txt missing.txt (where missing.txt doesn't exist). Which stream receives the error message? Submit either 'stdout' or 'stderr'." -Expected behavior: -- `good1.txt` appears (stdout) -- Error message about missing.txt appears (stderr) -Answer: `stderr` -Validation: -- Single word. -- Case-insensitive: 'stderr' or 'STDERR' accepted. -Hints: -1. "Try the command and observe where error messages go." -2. "Normal output goes to stdout; errors go to stderr." -3. "Answer: stderr" -Failure: -- 'stdout' submitted → "Error messages go to stderr, not stdout." - -### Level 9.2 – Redirecting Stderr -Start: `$WORKSPACE/level-9/mixed/` -Task: "List both good1.txt and missing.txt, but redirect ONLY errors to errors.log using 2>. After running the command, submit the basename of the error log file without extension." -Command: `ls good1.txt missing.txt 2> errors.log` -Answer: `errors` -Validation: -- File errors.log exists in current directory. -- Contains error message about missing.txt. -- Extension stripped. -Hints: -1. "Use: ls good1.txt missing.txt 2> errors.log" -2. "The 2> redirects only stderr to the file." -3. "Answer: errors" -Failure: -- File missing → "Did you create errors.log with 2>?" -- Extension included → "Remove extension." - -### Level 9.3 – Redirecting Both Streams -Start: `$WORKSPACE/level-9/commands/` -Task: "Run ./noisy_process.sh and redirect BOTH stdout and stderr to combined.log. Count the number of bytes in combined.log using wc -c. Submit that count." -Command: `./noisy_process.sh &> combined.log` or `./noisy_process.sh > combined.log 2>&1` -Content (approx 60 bytes including newlines): -``` -Processing... -Warning: Low memory -Done -``` -Answer: `60` (or exact byte count) -Validation: -- File combined.log exists. -- Contains both normal output and warning. -- Byte count matches. -Hints: -1. "Use: ./noisy_process.sh &> combined.log" -2. "Count bytes with: wc -c combined.log" -3. "Answer: 60" (adjust to actual) -Failure: -- Missing stderr content → "Both streams should be captured—use &> or 2>&1." - -### Level 9.4 – Silencing Output -Start: `$WORKSPACE/level-9/commands/` -Task: "Run ./failing_script.sh and suppress ALL output (both stdout and stderr) using /dev/null. The script will still fail (exit code 1), but produce no visible output. After running it silently, what is the name of the device file you redirected to? Submit just the device name (no path)." -Command: `./failing_script.sh &>/dev/null` or `./failing_script.sh >/dev/null 2>&1` -Answer: `null` -Validation: -- Single word. -- Accepts 'null' or 'dev/null' → normalize to 'null'. -Hints: -1. "Use: ./failing_script.sh &>/dev/null" -2. "/dev/null is a special device that discards all data." -3. "Answer: null" -Failure: -- Wrong answer → "The device is /dev/null; submit 'null'." - -### Level 9.5 – Capturing Command Errors -Start: `$WORKSPACE/level-9/debug/` -Task: "Run ./broken_command.sh and capture ONLY its error messages to debug.log using 2>. Read debug.log and find the first word of the error message. Submit that word." -Script produces: "ERROR: Configuration file not found" -First word: `ERROR:` -Answer: `ERROR` (with or without colon, normalize) -Validation: -- File debug.log exists. -- Contains error message. -- First word extracted (case-insensitive). -Hints: -1. "Use: ./broken_command.sh 2> debug.log" -2. "Read the file with: cat debug.log" -3. "First word is: ERROR" -Failure: -- Captured stdout instead → "Use 2> to capture stderr only." - -### Level 9.6 – (Optional) Complex Stream Control -Start: `$WORKSPACE/level-9/commands/` -Task: "Run ./noisy_process.sh three times: -1. Redirect stdout to success.log, stderr to errors.log -2. Append stdout to success.log, stderr to errors.log (using >>) -3. Append stdout to success.log, stderr to errors.log (using >>) -Count total lines in success.log and errors.log combined. Submit the total." -Commands: -``` -./noisy_process.sh > success.log 2> errors.log -./noisy_process.sh >> success.log 2>> errors.log -./noisy_process.sh >> success.log 2>> errors.log -``` -Expected: -- success.log: 6 lines (2 normal outputs × 3 runs) -- errors.log: 3 lines (1 warning × 3 runs) -- Total: 9 lines -Answer: `9` -Validation: -- Both files exist. -- Line count matches. -Hints: -1. "First run with >, subsequent with >> to append." -2. "Count total: wc -l success.log errors.log" -3. "Answer: 9" -Optional metadata: `optional=true` - -## 7. General Validation Rules (Section 9) -- Trim whitespace. -- File basename answers: strip extension. -- Single word answers: case-insensitive where appropriate. -- Integer answers: strict numeric parsing. -- Verify file existence and content. -- Execute commands in sandboxed environment to verify behavior. - -## 8. Hint Strategy -Three hints per level: -1. Command syntax with operator explanation. -2. Conceptual clarification of stream behavior. -3. Explicit answer or verification command. -Track `attempts` and `hints_used`. - -## 9. Telemetry / State Logging -Per completion: -``` -"9.n": { - "time_sec": <int>, - "attempts": <int>, - "hints": <int> -} -``` -Advancement: Levels 9.1–9.4 required. 9.5 Extension. 9.6 Optional. - -## 10. Failure Message Templates -- Wrong stream: "You redirected the wrong stream—check stdout vs stderr." -- File missing: "Output file not created—verify redirection syntax." -- Extension included: "Remove the extension." -- Both streams not captured: "Both stdout and stderr should be in the file—use &>." -- Wrong device: "The discard device is /dev/null." -- Incomplete capture: "Error message not found in file—verify 2> usage." - -## 11. Edge Cases & Robustness -- Scripts must be executable (chmod +x during setup). -- /dev/null always available on Unix systems. -- Order sensitivity: `2>&1 > file` vs `> file 2>&1` behave differently (teach correct order). -- Empty stderr: some commands succeed without errors (valid). -- Exit codes: can check but not required for submission (focus on streams). - -## 12. Implementation Checklist -- Pre-create all scripts with exact error messages. -- Ensure scripts are executable (chmod +x). -- Provide helpers: - - `file_exists(path)` → bool - - `file_contains(path, pattern)` → bool - - `count_lines(file)` → int - - `count_bytes(file)` → int - - `first_word(file)` → string -- Validate captured content matches expected stderr/stdout. -- Reset mechanism reconstructs Section 9 tree and removes created logs. - -## 13. Advancement Criteria -After Level 9.4 success: -- `current_level = "10.1"` -Optional Level 9.6 accessible post-advancement. - -## 14. Sample Instruction Screen (Level 9.3) -``` -══════════════════════════════════════════════════════ -LEVEL 9.3: Redirecting Both Streams - -Some commands produce both normal output (stdout) and -error/warning messages (stderr). You can capture both -to the same file. - -Task: -1. Navigate to level-9/commands/ -2. Run: ./noisy_process.sh &> combined.log -3. This captures BOTH streams in one file -4. Count bytes: wc -c combined.log -5. Submit the byte count - -Alternative syntax: ./noisy_process.sh > combined.log 2>&1 - -Submit with: shellgame submit <count> -Need help? Type: shellgame hint -══════════════════════════════════════════════════════ -``` - -## 15. Pedagogical Reinforcement Points -- Stream separation enables precise control of output and errors. -- /dev/null teaches the concept of intentional data disposal. -- Append vs overwrite distinction critical for log aggregation. -- Error capture essential for debugging production systems. -- Order of redirection operators matters (2>&1 position sensitivity). -- Real-world pattern: separating application logs from error logs. - -## 16. Future Cross-References -- Section 10 (wildcards): combined with redirection for batch logging. -- Section 11 (search): grep through error logs. -- Scripting contexts: proper error handling in automation. -- System administration: log rotation and error monitoring. - -## 17. Summary (Instructor View) -Section 9 completes the redirection model by introducing stderr as a controllable stream. Students gain debugging literacy—essential for troubleshooting commands, scripts, and system processes. The ability to capture, examine, or suppress error output transforms them from passive command executors to active system diagnosticians. - -Pacing Note: Prioritize stream distinction + redirection basics (9.1–9.4) for core timeline. - -End of Section 9 Specification. \ No newline at end of file diff --git a/docs/specs/sections/section11.md b/docs/specs/sections/section11.md deleted file mode 100644 index 6f70875..0000000 --- a/docs/specs/sections/section11.md +++ /dev/null @@ -1,353 +0,0 @@ -# Section 11 Specification – "Search & Discovery" - -## 1. Purpose & Scope -Section 11 introduces powerful search tools for locating files and content across directory trees. Building on all prior sections, students learn to: -- Use `find` to search for files by name, type, and attributes -- Use `grep -r` for recursive content searching -- Combine `find` with other commands using `-exec` -- Count files and content matches with `wc` -- Navigate large directory structures efficiently -- Build complex search queries with multiple criteria - -Deliberate exclusions: -- No advanced find predicates (`-mtime`, `-user`, `-perm` beyond basics) -- No xargs (introduce `-exec` instead) -- No locate/updatedb -- No advanced grep options (PCRE, context lines) -- No ack/ag/ripgrep alternatives - -Allowed commands: All previous commands plus `find`, `grep -r`, `grep -i`, `wc` -Estimated Time: 12–15 minutes (core Levels 11.1–11.6) + optional Level 11.7 (~3 minutes) -> Time Calibration: Target 9 minutes average; slow path 7 minutes (Levels 11.1–11.4). Levels 11.5–11.6 become Extension (size + case-insensitive), 11.7 Optional. - -## 2. Learning Objectives -By the end of Section 11 the player will: -1. Use `find` to locate files by name pattern. -2. Use `find` to search by file type (file vs directory). -3. Use `find` with size predicates. -4. Use `grep -r` to search for text patterns recursively. -5. Combine `grep -r` with `wc -l` to count matches across files. -6. Use `find` with `-exec` to perform actions on found files. -7. Build multi-criteria searches (name AND type AND size). -8. Search case-insensitively with `grep -i`. -9. (Optional) Construct complex find/grep pipelines for data extraction. - -## 3. Concept Tutorial (Displayed Before Level 11.1) -Key concepts: -- **`find`**: Searches directory trees for files matching criteria - - Syntax: `find <path> <predicates>` - - Common predicates: - - `-name "pattern"`: match filename (glob pattern, quoted) - - `-type f`: files only - - `-type d`: directories only - - `-size +1M`: larger than 1 megabyte - - `-size -100c`: smaller than 100 bytes - - `-exec command {} \;`: execute command on each match - - Example: `find . -name "*.txt"` finds all .txt files in current tree -- **`grep -r`**: Searches file contents recursively - - Syntax: `grep -r "pattern" <path>` - - Returns: filename:matching_line - - Example: `grep -r "ERROR" logs/` finds all ERROR mentions in logs/ -- **`grep -i`**: Case-insensitive search - - `grep -i "error"` matches ERROR, Error, error -- **Combining tools**: - - `find . -name "*.log" -exec grep "ERROR" {} \;` - - `grep -r "pattern" | wc -l` counts matching lines - - `find . -type f | wc -l` counts all files -- **Search efficiency**: narrow search path to speed results - -Visual example: -``` -project/ -├── src/ -│ ├── main.py -│ └── utils.py -├── tests/ -│ └── test_main.py -└── docs/ - └── readme.txt - -find . -name "*.py" -→ ./src/main.py -→ ./src/utils.py -→ ./tests/test_main.py - -grep -r "def main" -→ ./src/main.py:def main(): -→ ./tests/test_main.py:def main_test(): -``` - -Short prompt: -"Find files anywhere. Search content everywhere. Narrow by criteria. Extract precisely what you seek." - -## 4. Directory Layout (Initial for Section 11) -Base: `$WORKSPACE/level-11/` - -Proposed structure: -``` -level-11/ -├── docs/ -│ ├── report.txt (content: "Annual summary 2025") -│ ├── notes.txt (content: "Meeting notes") -│ ├── draft.md (content: "Draft proposal") -│ └── archive/ -│ ├── old_report.txt (content: "Legacy data") -│ └── backup.txt (content: "Backup copy") -├── logs/ -│ ├── app.log (content: 50 lines, 5 contain "ERROR") -│ ├── system.log (content: 80 lines, 3 contain "ERROR") -│ ├── debug.log (content: 30 lines, 0 contain "ERROR") -│ └── archive/ -│ ├── old_app.log (content: 100 lines, 8 contain "ERROR") -│ └── old_system.log (content: 60 lines, 2 contain "ERROR") -├── code/ -│ ├── main.py (content: Python code with "def main") -│ ├── utils.py (content: Python code with "def helper") -│ ├── config.json (content: JSON configuration) -│ └── lib/ -│ ├── parser.py (content: Python code with "def parse") -│ └── validator.py (content: Python code with "def validate") -├── mixed/ -│ ├── small.txt (50 bytes) -│ ├── medium.dat (500 bytes) -│ ├── large.bin (5000 bytes) -│ ├── tiny.log (10 bytes) -│ └── data/ -│ ├── file1.txt (200 bytes) -│ ├── file2.txt (300 bytes) -│ └── file3.dat (400 bytes) -└── search-target/ - ├── password.txt (content: "SECRET: admin123") - ├── config.yaml (content: "password: changeme") - └── deep/ - └── nested/ - └── credentials.conf (content: "PASSWORD=test123") -``` - -## 5. Level Index -| ID | Title | Focus | Answer Type | -|-------|------------------------------------|----------------------------------------|-----------------------| -| 11.1 | Finding by Name | find -name pattern | Integer (count) | -| 11.2 | Finding by Type | find -type f/d | Integer (count) | -| 11.3 | Recursive Content Search | grep -r pattern | File basename | -| 11.4 | Count Matching Lines | grep -r \| wc -l | Integer (count) | -| 11.5 | Finding by Size | Extension | File basename | -| 11.6 | Case-Insensitive Search | Extension | Integer (count) | -| 11.7 | (Optional) Complex Search Combo | Optional | Ordered list | - -## 6. Detailed Level Specifications - -### Level 11.1 – Finding by Name -Start: `$WORKSPACE/level-11/` -Task: "Find all files named '*.txt' (anywhere in level-11 tree) using find. Count them with: find . -name '*.txt' | wc -l. Submit the count." -Files matching: report.txt, notes.txt, old_report.txt, backup.txt, small.txt, file1.txt, file2.txt, password.txt (8 files) -Answer: `8` -Validation: -- Integer. -- Matches actual count of .txt files recursively. -Hints: -1. "Use: find . -name '*.txt' | wc -l" -2. "Quote the pattern to prevent shell expansion." -3. "Answer: 8" -Failure: -- Wrong count → "Verify: find . -name '*.txt'" - -### Level 11.2 – Finding by Type -Start: `$WORKSPACE/level-11/code/` -Task: "Count only directories (not files) under current directory using find . -type d. Submit the count (excluding . itself)." -Directories: lib/ (1 subdirectory) -Answer: `1` -Validation: -- Integer. -- Excludes starting directory (.). -Hints: -1. "Use: find . -type d" -2. "-type d finds directories only." -3. "Exclude current dir (.); answer: 1" -Failure: -- Includes current dir → "Exclude the starting directory (.)." - -### Level 11.3 – Recursive Content Search -Start: `$WORKSPACE/level-11/docs/` -Task: "Search recursively for files containing the word 'Legacy' using grep -r 'Legacy' . Find the basename (without extension) of the file containing it." -File containing "Legacy": archive/old_report.txt -Answer: `old_report` -Validation: -- Single file basename. -- Extension stripped. -Hints: -1. "Use: grep -r 'Legacy' ." -2. "Output shows filename:line with match." -3. "Answer: old_report" -Failure: -- Extension included → "Remove extension." -- Full path → "Submit basename only." - -### Level 11.4 – Count Matching Lines -Start: `$WORKSPACE/level-11/logs/` -Task: "Count total lines containing 'ERROR' across all .log files recursively. Use: grep -r 'ERROR' . | wc -l. Submit the count." -Matches: app.log (5) + system.log (3) + old_app.log (8) + old_system.log (2) = 18 lines -Answer: `18` -Validation: -- Integer. -- Matches total across all log files. -Hints: -1. "Use: grep -r 'ERROR' . | wc -l" -2. "This counts matching lines across all files." -3. "Answer: 18" -Failure: -- Wrong count → "Ensure searching all .log files recursively." - -### Level 11.5 – Finding by Size -Start: `$WORKSPACE/level-11/mixed/` -Task: "Find files larger than 1000 bytes using find . -size +1000c. Submit the basename (without extension) of the largest file found." -Files >1000 bytes: large.bin (5000 bytes) -Answer: `large` -Validation: -- File basename. -- Extension stripped. -- Matches largest file. -Hints: -1. "Use: find . -size +1000c" -2. "+1000c means more than 1000 bytes." -3. "Answer: large" -Failure: -- Wrong file → "Find files larger than 1000 bytes." -- Extension included → "Remove extension." - -### Level 11.6 – Case-Insensitive Search -Start: `$WORKSPACE/level-11/search-target/` -Task: "Count files containing 'password' or 'PASSWORD' (case-insensitive) using grep -ri 'password' . | wc -l. Submit the count of matching lines." -Matches: -- password.txt: 1 line ("SECRET: admin123" contains no password word) - WAIT, file is named password.txt but we search content -- config.yaml: 1 line ("password: changeme") -- credentials.conf: 1 line ("PASSWORD=test123") -Total: 2 lines (config.yaml + credentials.conf) -Answer: `2` -Validation: -- Integer. -- Case-insensitive matches. -Hints: -1. "Use: grep -ri 'password' . | wc -l" -2. "-i makes search case-insensitive." -3. "Answer: 2" -Failure: -- Case-sensitive count → "Use -i flag for case-insensitive." - -### Level 11.7 – (Optional) Complex Search Combo -Start: `$WORKSPACE/level-11/code/` -Task: "Find all .py files containing the word 'def', then count how many unique files (not lines) contain it. Use: find . -name '*.py' -exec grep -l 'def' {} \; | wc -l. Submit the count." -Python files with 'def': main.py, utils.py, parser.py, validator.py (4 files) -Answer: `4` -Validation: -- Integer. -- Counts unique files, not lines. -- -l flag lists filenames only. -Hints: -1. "Use: find . -name '*.py' -exec grep -l 'def' {} \\;" -2. "grep -l lists filenames only (not lines)." -3. "Answer: 4" -Optional metadata: `optional=true` - -## 7. General Validation Rules (Section 11) -- Trim whitespace. -- Integer answers: strict numeric parsing. -- File basename answers: strip extension. -- Ordered lists: comma-separated, alphabetically sorted. -- Find output: one result per line. -- Grep output format: filename:line_content. -- Case sensitivity: respect unless -i flag used. - -## 8. Hint Strategy -Three hints per level: -1. Command syntax with flags. -2. Explanation of predicate or option behavior. -3. Explicit answer or verification command. -Track `attempts` and `hints_used`. - -## 9. Telemetry / State Logging -Per completion: -``` -"11.n": { - "time_sec": <int>, - "attempts": <int>, - "hints": <int> -} -``` -Advancement: Levels 11.1–11.4 required. 11.5–11.6 Extension. 11.7 Optional. - -## 10. Failure Message Templates -- Wrong count: "Count does not match—verify search criteria." -- Extension included: "Remove the extension." -- Full path submitted: "Submit basename only." -- Case sensitivity error: "Use -i for case-insensitive search." -- Wrong predicate: "Check find predicate syntax." -- File vs directory confusion: "Use -type f for files, -type d for directories." - -## 11. Edge Cases & Robustness -- Empty search results: valid (count = 0). -- Symbolic links: follow or not (find default: follow). -- Permission denied: may appear in output (filter with 2>/dev/null if needed). -- Quote protection: patterns must be quoted to prevent shell expansion. -- Large trees: search may be slow (acceptable in game environment). - -## 12. Implementation Checklist -- Pre-create all files with exact content and sizes. -- Provide helpers: - - `find_files(path, name_pattern)` → list - - `grep_recursive(path, pattern, case_insensitive)` → list - - `count_matches(results)` → int - - `strip_extension(filename)` → string -- Validate search results match expected counts. -- Reset mechanism reconstructs Section 11 tree. -- Ensure file sizes exact for size-based searches. - -## 13. Advancement Criteria -After Level 11.4 success: -- Game complete (core). Offer Extension levels then Optional 11.7. -- Show completion summary with: - - Total time - - Total hints used - - All sections completed - - Optional challenges completed - -## 14. Sample Instruction Screen (Level 11.4) -``` -══════════════════════════════════════════════════════ -LEVEL 11.4: Count Matching Lines - -grep -r searches file contents recursively. Pipe to wc -l -to count total matching lines across all files. - -Task: -1. Navigate to level-11/logs/ -2. Search for 'ERROR' in all files: grep -r 'ERROR' . -3. Count matches: grep -r 'ERROR' . | wc -l -4. Submit the total count - -This searches ALL files recursively, including subdirectories. - -Submit with: shellgame submit <count> -Need help? Type: shellgame hint -══════════════════════════════════════════════════════ -``` - -## 15. Pedagogical Reinforcement Points -- find enables file location by attributes, not just content. -- grep -r scales content search across entire trees. -- Combining tools (find + grep + wc) creates powerful queries. -- Case-insensitive search essential for user-generated content. -- -exec demonstrates command chaining at filesystem level. -- Real-world: log analysis, codebase exploration, security audits. - -## 16. Future Cross-References -- Scripting: loops over find results. -- Performance: learn when to narrow search paths. -- Security: finding sensitive data (passwords, keys). - -## 17. Summary (Instructor View) -Section 11 equips students with industrial-strength search capabilities. The find/grep combination is fundamental to system administration, security analysis, and software development. By mastering recursive search patterns, students gain the ability to interrogate large codebases and filesystems efficiently—transforming from manual navigators to automated discoverers. - -Pacing Note: Core focuses on name/type/content + counts before advanced predicates. - -End of Section 11 Specification. \ No newline at end of file diff --git a/docs/specs/sections/section2.md b/docs/specs/sections/section2.md deleted file mode 100644 index f075f20..0000000 --- a/docs/specs/sections/section2.md +++ /dev/null @@ -1,255 +0,0 @@ -# Section 2 Specification – "Moving With Purpose: Files & Navigation" - -## 1. Purpose & Scope -Section 2 deepens navigation fluency while introducing minimal file interaction: -- Reinforces relative path reasoning (including multi-up sequences and sibling traversal). -- Integrates reading existing file contents using `cat`. -- Introduces file creation using `touch`. -- Strengthens mental modeling of directory layout without visual aids. -- Avoids hidden files (deferred to Section 3), permissions, deletion, or editing. - -Allowed commands in this section: `pwd`, `ls`, `cd`, `cat`, `touch` -(No `ls -l`, no `echo >`, no `tree`, no wildcards, no `rm` yet.) - -Estimated Time: 8–12 minutes (core Levels 2.1–2.4) + optional Level 2.5 (~2 minutes). -> Time Calibration: Target 7 minutes average. Core = 2.1–2.4 (navigation + reading). 2.5 becomes Extension (clue chain). 2.6 remains Optional (creation). Slow path may skip 2.5–2.6. - -## 2. Learning Objectives -By the end of Section 2 the player can: -1. Traverse multi-level and sibling paths confidently using only relative movements. -2. Use `cd -` to toggle between the current and previous working directory. -3. Use `cat` to extract a single word (first word) from a file without altering the file. -4. Create an empty file with `touch` in a target directory after deliberate navigation. -5. Follow a chain of directory clues encoded inside files to reach a final destination. -6. Avoid over-reliance on starting position by reorienting with `pwd` and planning movements. -7. Recognize that an empty file (after `touch`) has no content when displayed with `cat`. - -## 3. Concept Tutorial (Shown Before Level 2.1) -Concept Focus: -- Relative navigation review: `.` (current), `..` (parent), sibling movement by name. -- Toggling directories with `cd -` to quickly jump back. -- Sequential navigation planning: break longer moves into deliberate steps. -- Using `cat <file>` to display file contents; reading only, not editing. -- Using `touch <file>` to create an empty file if it does not exist (or update its timestamp if it exists). -- Empty file behavior: `cat` prints nothing (just returns). - -Key Principle: -“Navigate with intent. Confirm location with pwd. Read to discover; create only when instructed.” - -Mini ASCII Example (dynamic in implementation): -``` -level-2/ -├── reading/ -│ ├── poem.txt (content: "autumn leaves") -│ └── clue.txt (content: "travel") -├── travel/ -│ └── next.txt (content: "chain") -├── chain/ -│ └── final.dat (content: "arrival complete") -├── create-zone/ -│ └── placeholder.md -└── multi/ - └── a/b/c/target.txt (content: "summit reached") -``` - -## 4. Directory Layout (Initial for Section 2) -Base path: `$WORKSPACE/level-2/` - -Proposed structure (deterministic to simplify validation): -``` -level-2/ -├── start/ (starting directory for some levels) -│ ├── alpha/ -│ │ └── note.txt (content: "first step") -│ ├── beta/ -│ │ └── word.md (content: "momentum gained") -│ └── gamma/ -│ └── hold.txt (content: "pause briefly") -├── multi/ -│ └── a/ -│ └── b/ -│ └── c/ -│ └── target.txt (content: "summit reached") -├── reading/ -│ ├── poem.txt (content: "autumn leaves") -│ └── clue.txt (content: "travel") -├── travel/ -│ └── next.txt (content: "chain") -├── chain/ -│ └── final.dat (content: "arrival complete") -├── create-zone/ -│ └── placeholder.md -├── toggle-zone/ -│ ├── one/ -│ │ └── file.a -│ └── two/ -│ └── file.b -└── sandbox/ (empty; may be used for optional task) -``` - -Clue Chain Intended Flow (Level 2.4): -1. `reading/clue.txt` → contains: `travel` -2. `travel/next.txt` → contains: `chain` -3. `chain/final.dat` → final content first word: `arrival` - -## 5. Level Index -| ID | Title | Focus | Answer Type | -|------|----------------------------------|-----------------------------------------|----------------------| -| 2.1 | Sibling Navigation Drill | Relative movement across siblings | File basename (no ext) | -| 2.2 | Previous Directory Toggle | Using `cd -` to jump back | Directory basename | -| 2.3 | Deep Relative Ascent & Descent | Multi-hop navigation (../ chains) | Single word | -| 2.4 | Reading a File’s First Word | Using cat to extract first word | Single word | -| 2.5 | Chained Clue Traversal | Extension (do after 2.4 if time) | Single word | -| 2.6 | (Optional) Create & Confirm | Optional | File basename | - -## 6. Detailed Level Specifications - -### Level 2.1 – Sibling Navigation Drill -Start: `$WORKSPACE/level-2/start/alpha/` -Task: Move to siblings `beta` then `gamma` (via parent directory) and finally submit the basename of the file inside `gamma` without its extension. -Target File: `gamma/hold.txt` (basename: `hold`) -Answer: `hold` -Required Steps: -1. `pwd` (confirm in alpha) -2. `cd ..` (to start) -3. `cd beta`, inspect with `ls`, optional `cat word.md` -4. `cd ..`, then `cd gamma` -5. `ls` to find `hold.txt` -6. Submit `hold` -Validation: -- Must not include `.txt` -Failure Modes: -- Submit `hold.txt` → message clarifying extension removal. -- Submit while still in `beta` → location warning. -Hints: -1. "Move to parent with cd .. to reach siblings." -2. "Visit gamma and list files; drop the extension." -3. "Answer is hold" - -### Level 2.2 – Previous Directory Toggle -Start: `$WORKSPACE/level-2/toggle-zone/one/` -Task: "You are in the `one` directory. Navigate to the `two` directory. Then, use `cd -` to jump back to your previous location (`one`). Submit the basename of the directory you land in." -Steps: -1. `pwd` (confirms you are in `.../one`) -2. `cd ../two` (navigate to `two`) -3. `pwd` (confirms you are in `.../two`) -4. `cd -` (toggles back to `one`) -5. `pwd` (confirms you are back in `.../one`) -Answer: `one` -Validation: -- The final directory must be `one`. -- The answer must be the directory basename. -Failure Modes: -- Submitting `two` -> "You submitted your current directory, not the one you toggled back to." -Hints: -1. "After moving to `../two`, use `cd -` to return." -2. "The `cd -` command takes you to the directory you were in before your last `cd`." -3. "The answer is `one`." - -### Level 2.3 – Deep Relative Ascent & Descent -Setup: Player is moved to `$WORKSPACE/level-2/multi/a/b/c/` -Task: Ascend two levels in one command, then descend into `b/c/` again using separate steps, finally read `target.txt` first word. -Steps Example: -- From `.../a/b/c/` run `cd ../..` → now in `a` -- Then `cd b`, `cd c` -- `cat target.txt` → prints: `summit reached` -Answer: `summit` (first word only) -Validation: -- Must be exactly first token. -- Require that final `pwd` ends with `/c` before accept (ensures descent done). -Failure: -- User submits both words → instruct single word. -- Wrong ascent path → encourage checking with `pwd`. -Hints: -1. "Use cd ../.. to go up two levels." -2. "Re-enter b then c one directory at a time." -3. "First word is summit" - -### Level 2.4 – Reading a File’s First Word -Start: `$WORKSPACE/level-2/reading/` -Task: Read `poem.txt` and submit its first word. -Content: `autumn leaves` -Answer: `autumn` -Rules: -- Ignore second word. -- No modification of file. -Validation: -- Case-insensitive for content word. -- Reject multiple words. -Hints: -1. "List files; identify poem.txt." -2. "Use cat poem.txt and take only the first word." -3. "Submit: autumn" -Failure Examples: -- Input: `autumn leaves` → "Expected a single word." -- Input: `poem` → "That is not the file content; read the file." - -### Level 2.5 – Chained Clue Traversal -Start: `$WORKSPACE/level-2/reading/` -Instruction File Sequence: -- `reading/clue.txt` → contains `travel` -- `travel/next.txt` → contains `chain` -- `chain/final.dat` → contains `arrival complete` -Task: -1. Read `clue.txt` → navigate into `travel/` -2. Read `next.txt` → navigate into `chain/` -3. Read `final.dat` → submit first word -Answer: `arrival` -Validation: -- Ensure final directory is `chain` when answering. -- Must be first word only. -Hints: -1. "Each file’s first word is the next directory name (except final)." -2. "clue.txt → travel; next.txt → chain." -3. "Final first word: arrival" -Failure: -- If user guesses chain prematurely → "Navigate fully to final.dat and read its content." -Edge Case: -- If user jumps directly without reading: allow navigation but still requires correct word; hints encourage reading for authenticity. - -### Level 2.6 – (Optional) Create & Confirm -Start: `$WORKSPACE/level-2/create-zone/` -Task: Create an empty file named `checkpoint.flag` using `touch` and submit its basename without extension. -Answer: `checkpoint` -Instructions: -1. Confirm current directory with `pwd` -2. Run `touch checkpoint.flag` -3. Use `ls` to verify presence -4. Submit `checkpoint` -Validation: -- Must exist as a file. -- Extension stripped. -Failure: -- File missing → "File not found—did you create it with touch?" -- Submitted with extension → extension guidance. -Hints: -1. "Use touch to create the file." -2. "List files; remove extension when submitting." -3. "Answer: checkpoint" -Optional metadata: `optional=true` - -## 7. General Validation Rules (Section 2) -- Trim whitespace. -- File basename answers: strip single trailing extension (`name.ext` → `name`). -- Content word answers: single token only. -- For levels requiring navigation state (2.1, 2.2, 2.3, 2.5, 2.6), verify `pwd` matches expected target path prefix. -- Case sensitivity: basenames are case-sensitive; content words (from files) case-insensitive as per global spec philosophy. -- Creation check (2.6): confirm file existence; treat size as irrelevant (no size validation yet). - -## 8. Hint Design & Pedagogy -Three-tier hint ladder: -1. Command recall / orientation (e.g., "Use cd ..", "List with ls"). -2. Strategy articulation (multi-up pattern, identify next directory name). -3. Near-answer or explicit answer for early formative reinforcement. -Record `hints_used` and `attempts` per level for telemetry. - -## 9. Telemetry / State Logging -Per level completion: -``` -"2.n": { - "time_sec": <int>, - "attempts": <int>, - "hints": <int> -} -``` -Advancement condition: Levels 2.1–2.4 completed. Level 2.5 Extension; 2.6 Optional. \ No newline at end of file diff --git a/docs/specs/sections/section3.md b/docs/specs/sections/section3.md deleted file mode 100644 index e4ac046..0000000 --- a/docs/specs/sections/section3.md +++ /dev/null @@ -1,250 +0,0 @@ -# Section 3 Specification – "Hidden vs Visible" - -## 1. Purpose & Scope -Section 3 introduces hidden entries in the filesystem (names beginning with a dot `.`) and contrasts them with visible (non-dot-prefixed) entries. Building on navigation and simple file interaction from Sections 1–2, this section focuses on: -- Recognizing hidden files and directories with `ls -a` -- Counting hidden directories accurately (excluding `.` and `..`) -- Reading content from hidden files safely -- Navigating into hidden directories -- Interpreting multi-part filenames (extension / suffix awareness reinforcing earlier concepts) - -Deliberate exclusions: -- No creation or deletion commands here (`mkdir`, `rm`, etc.) — deferred to Section 4 -- No permission changes -- No pattern globbing (`*`, `?`) yet -- No size or metadata inspection (`ls -l`) - -Allowed commands: `pwd`, `ls`, `ls -a`, `cd`, `cat` (introduced in Section 2; reused here) -Estimated Time: 5–8 minutes for core levels (3.1–3.3) + optional Level 3.4 (~2 minutes) -> Time Calibration: Target 5 minutes average; slow path 4 minutes (Levels 3.1–3.2 only). Level 3.3 now Extension for very slow students (can defer), 3.4 remains Optional. - -## 2. Learning Objectives -By the end of Section 3 the player will: -1. Explain what makes a file or directory “hidden” (leading dot). -2. Use `ls -a` to reveal hidden entries reliably. -3. Distinguish hidden files from hidden directories via inspection (`ls -a` then `cd` attempt). -4. Count only hidden directories excluding the pseudo entries `.` and `..`. -5. Read the first word of a hidden file’s content. -6. Navigate inside a hidden directory and count its real files (excluding `.` and `..` if listed with `ls -a`). -7. (Optional) Extract the basename or final suffix segment from a multi-dot hidden filename. - -## 3. Concept Tutorial (Displayed Before Level 3.1) -Highlights: -- Hidden entries start with `.` (examples: `.config`, `.cache`, `.env`, `.notes.txt`) -- `ls` does NOT list hidden entries; use: `ls -a` to show all (including `.` and `..`) -- `.` = current directory; `..` = parent directory — not real files for counting purposes -- Hidden status is a naming convention, not a permission change -- Reading hidden files uses the same `cat` command -- Basename vs extension refresher: In `.backup.archive.log` the basename concept we use is everything before the first dot for hidden naming exercises OR we may request the last segment after the final dot (specify per level) - -Mini Example: -``` -(hidden-play/) -├── visible.txt -├── .secret/ -│ └── clue.md (content: "vault key") -├── .config -├── .archive.old (content: "snapshot legacy") -└── .notes.txt (content: "daily summary") -``` - -## 4. Directory Layout (Initial for Section 3) -Base path: `$WORKSPACE/level-3/` - -Proposed structure: -``` -level-3/ -├── hub/ (starting directory for Level 3.1) -│ ├── .alpha -│ ├── .beta/ -│ │ └── readme.txt (content: "hidden journey") -│ ├── .gamma/ -│ │ └── info.md (content: "stealth mode") -│ ├── visible/ -│ │ └── marker.txt (content: "standard entry") -│ ├── .delta.log (content: "log initialized") -│ └── .epsilon.archive.old (content: "historic bundle") -├── hidden-read/ (Level 3.2 focus) -│ └── .schedule.txt (content: "Tuesday events") -├── deep/ -│ └── .vault/ -│ ├── door.txt (content: "open sesame") -│ ├── key.md (content: "silver token") -│ └── .inner-secret (empty file) -└── suffix-lab/ - └── .multi.segment.name.final.txt (content: "multi hidden example") -``` - -Notes: -- `.alpha` is a hidden file (empty or minimal content not required). -- `.beta` and `.gamma` are hidden directories. -- Counting tasks will differentiate directories vs files. -- Optional suffix extraction uses multi-dot hidden filename in `suffix-lab/`. - -## 5. Level Index -| ID | Title | Focus | Answer Type | -|------|-------------------------------------|-----------------------------------------|------------------------| -| 3.1 | Hidden Directory Count | Use ls -a, exclude . and .. | Integer count | -| 3.2 | Reading a Hidden File | cat on hidden file | Single word (content) | -| 3.3 | Inside a Hidden Directory | Extension (can defer) | Integer count | -| 3.4 | (Optional) Hidden Filename Suffix | Optional | Single word (suffix) | - -## 6. Detailed Level Specifications - -### Level 3.1 – Hidden Directory Count -Start Location: `$WORKSPACE/level-3/hub/` -Task: "List all entries including hidden. Count hidden directories (names starting with '.') excluding '.' and '..'. Submit the count." -Hidden Directories Present: `.beta`, `.gamma` -Hidden Non-Directory Files: `.alpha`, `.delta.log`, `.epsilon.archive.old` -Answer: `2` -Validation: -- Must be integer. -- Confirm candidate names actually directories (stat check in implementation). -Failure Modes: -- Counting hidden files → "You included hidden files; count only directories." -- Non-integer input → "Expected an integer." -Hints: -1. "Use ls -a to reveal hidden entries." -2. "Filter for names starting with '.' that are directories." -3. "There are 2 hidden directories." - -### Level 3.2 – Reading a Hidden File -Start Location: `$WORKSPACE/level-3/hidden-read/` -Target File: `.schedule.txt` with content: `Tuesday events` -Task: "Read the hidden file `.schedule.txt` and submit its first word." -Answer: `Tuesday` -Rules: -- First word only. -- Case-insensitive acceptance (consistent with prior content word rules). -Failure: -- Submitting both words → "Expected a single word." -- Submitting filename → "Submit the first word from the file content." -Hints: -1. "Use ls -a to see the file." -2. "Use cat .schedule.txt; take the first word only." -3. "Answer: Tuesday" - -### Level 3.3 – Inside a Hidden Directory -Start Location: `$WORKSPACE/level-3/deep/` -Goal: Enter hidden directory `.vault/` then count regular files inside (exclude `.` and `..`; none of its entries are directories except possibly `.inner-secret` which is a hidden file). -Contents inside `.vault/`: `door.txt`, `key.md`, `.inner-secret` -Task: "Navigate into .vault and count the number of files present (include hidden file `.inner-secret` in the count; exclude '.' and '..')." -Answer: `3` -Validation: -- Verify current directory ends with `.vault` -- Count all entries except `.` and `..` regardless of leading dot -Failure: -- Not inside `.vault` → "You are not in .vault—check with pwd." -- Wrong count (e.g., 2) → "Did you include the hidden file starting with a dot?" -Hints: -1. "cd .vault after listing with ls -a." -2. "List all entries; ignore . and ..." -3. "There are 3 files." - -### Level 3.4 – (Optional) Hidden Filename Suffix -Start Location: `$WORKSPACE/level-3/suffix-lab/` -Target Filename: `.multi.segment.name.final.txt` -Task: "Submit the final suffix (the word before the last dot extension) from `.multi.segment.name.final.txt`. Ignore the leading dot and the final extension." -Explanation: -- Split on dots: ['', 'multi', 'segment', 'name', 'final', 'txt'] -- Leading empty segment (due to starting dot) ignored. -- Last segment 'txt' is the extension. -- Desired suffix: `final` -Answer: `final` -Failure Modes: -- `final.txt` submitted → "Remove the extension." -- `multi` / other segment → "That is not the final suffix before extension." -Hints: -1. "Split the name by dots; ignore the leading empty part." -2. "Drop the last segment (the extension)." -3. "Answer: final" -Optional metadata: `optional=true` - -## 7. General Validation Rules (Section 3) -- Trim whitespace. -- Integer answers: strict digit parse. -- Content word answers: single word; case-insensitive compare. -- Suffix extraction: case-sensitive (filename semantics). -- Location-dependent tasks (3.3, 3.4): verify `pwd` ends with expected directory. -- Hidden directory count excludes `.` and `..`. -- For multi-dot filename parsing, define: segments = name.split('.'); discard empty first; extension = last; suffix = second-to-last. - -## 8. Hint System -Three hints per level: -1. Command recall / orientation (`ls -a`, `cd .vault`) -2. Strategy refinement (filter, splitting, counting logic) -3. Near-answer or full answer for reinforcement (especially early hidden concepts) -Record `hints_used` and `attempts`. - -## 9. Telemetry / State Logging -Per completion: -``` -"3.n": { - "time_sec": <int>, - "attempts": <int>, - "hints": <int> -} -``` -Advancement condition: Complete Levels 3.1–3.2 (3.3 Extension, 3.4 Optional). - -## 10. Failure Message Templates -- Hidden count includes files: "Count only hidden directories; you included hidden files." -- Not inside target directory: "You are at a different path—use pwd to confirm." -- Multiple words: "Expected a single word." -- Wrong suffix: "Not the final segment before the extension—inspect the filename again." -- Extension included: "Remove the extension; submit only the suffix." -- Non-integer: "Expected an integer count." - -## 11. Edge Cases & Robustness -- Additional user-created hidden entries: Accept natural variation but instruct reset if counts differ from canonical. (Implementation may snapshot expected directory for Level 3.1.) -- Empty hidden file: If user tries to read a hidden file with no content (not in current design), answer must be clarified as "File appears empty—reset level." -- Unusual home path irrelevant here (using workspace only). -- If user renames files: Validation fails; instruct reset. - -## 12. Implementation Checklist -- Create directory tree and files before level start. -- Provide helper: - - `is_hidden(name)` → name.startswith('.') - - `hidden_dir_count(path)` filtering directories excluding `.` and `..` - - `extract_suffix(filename)` logic as specified. -- Validation ensures current path for location-sensitive tasks. -- Reset routine recreates minimal structure for section. -- Hints stored as per-level arrays of length 3. - -## 13. Advancement Criteria -After successful completion of Level 3.2: -- Set `current_level = "4.1"` -- Preserve ability to attempt 3.4 later without regression. - -## 14. Sample Instruction Screen (Level 3.1) -``` -══════════════════════════════════════════════════════ -LEVEL 3.1: Hidden Directory Count - -Hidden entries start with a dot. Use ls -a to see them. -Count ONLY hidden directories here (exclude . and ..). -Submit the count as a single integer. - -Commands: ls -a, pwd - -Submit with: shellgame submit <number> -Need help? Type: shellgame hint -══════════════════════════════════════════════════════ -``` - -## 15. Pedagogical Reinforcement Points -- Visual differentiation: `ls` vs `ls -a` encourages conceptual shift. -- Counting task anchors definition of hidden directories vs files. -- Reading hidden file extends prior skill—hidden does not mean special access. -- Directory traversal into hidden directory normalizes hidden navigation. -- Optional suffix extraction consolidates multi-dot parsing and extension awareness. - -## 16. Future Cross-References -- Section 4: creation & cleanup (mkdir, rm) will include making hidden directories. -- Later: pattern matching may target hidden names explicitly. -- Permissions section (later) clarifies hidden ≠ protected. - -## 17. Summary (Instructor View) -Section 3 isolates hidden visibility mechanics, ensuring learners confidently list, classify, and interact with hidden entries before adding creation/deletion complexity. Focus remains narrow to solidify conceptual clarity and avoid cognitive overload prior to structural modifications in Section 4. - -Pacing Note: Permit advancement after 3.2 when compressing schedule; revisit directory traversal (3.3) later. \ No newline at end of file diff --git a/docs/specs/sections/section4.md b/docs/specs/sections/section4.md deleted file mode 100644 index 5c60e57..0000000 --- a/docs/specs/sections/section4.md +++ /dev/null @@ -1,287 +0,0 @@ -# Section 4 Specification – "Creating & Cleaning Your World" - -## 1. Purpose & Scope -Section 4 introduces controlled creation and cleanup of filesystem elements inside the isolated game workspace. Students move from passive navigation (Sections 1–3) to intentional environment shaping: -- Creating files and directories (`touch`, `mkdir`, `mkdir -p`) -- Understanding when a directory is considered “empty” -- Removing files (`rm`) and empty directories (`rmdir`) -- Handling non-empty directory removal attempts (failure feedback) -- Building small multi-level structures consciously -- Practicing safe removal patterns without risking system data - -No editing tools (nano/vim), no permissions (`chmod`), no wildcard-heavy batch operations (those come later in Section 9). Wildcards may be referenced ONCE as an optional enhancement but not required for progression. - -Estimated Time: 12–15 minutes (core Levels 4.1–4.6) + optional Level 4.7 (~3 minutes). - -> Time Calibration: Target 9 minutes average; slow path 7 minutes (Levels 4.1–4.5). Level 4.6 becomes Extension (scaffold building), 4.7 Optional. Emphasize separation of create vs clean before complex scaffold. - -## 2. Learning Objectives -By completion of Section 4 the player can: -1. Create an empty file and verify it exists. -2. Create a single directory and confirm its basename. -3. Use `mkdir -p` to create nested directory chains in one command. -4. Distinguish between removing a file (`rm file`) and removing an empty directory (`rmdir dir`). -5. Discover why `rmdir` fails on non-empty directories and identify the remaining item. -6. Manually remove a subset of files based on an extension. -7. Construct a mini “project scaffold” and count directories created. -8. (Optional) Use a safe pattern removal (introducing cautious wildcard thinking). - -## 3. Concept Tutorial (Displayed Before Level 4.1) -Key points: -- `touch name` creates an empty file. -- `mkdir dir` creates a single directory; fails if it already exists. -- `mkdir -p a/b/c` builds nested structure; intermediate directories created automatically. -- Removing: - - `rm file` deletes a file. - - `rmdir dir` only succeeds if `dir` is empty. -- Non-empty removal: `rmdir` error reinforces inspection (use `ls`) before deletion. -- Workspace safety: operations limited to `/tmp/...` game sandbox. -- Terminology: - - “Empty directory” = contains no entries besides implicit `.` and `..`. - - “Scaffold” = planned multi-directory hierarchy. - -Short prompt: -“Create carefully, confirm with ls/pwd, then clean what you created. Understand empty vs non-empty and intentional structure building.” - -## 4. Directory Layout (Initial for Section 4) -Base: `$WORKSPACE/level-4/` - -Initial structure before any tasks: -``` -level-4/ -├── staging/ -├── cleanup/ -│ ├── keep.txt (content: "persist") -│ ├── old.tmp (empty) -│ ├── stray.tmp (empty) -│ └── data.txt (content: "value stream") -├── scaffold/ -└── obstacle/ - └── nested/ - └── token.md (content: "depth") -``` - -Notes: -- `staging/` starts empty for file/directory creation exercises. -- `cleanup/` contains two `.tmp` files targeted for manual removal task, plus survivors. -- `obstacle/nested/` holds a file to enforce non-empty directory removal failure. -- `scaffold/` starts empty for multi-level construction. - -## 5. Level Index -| ID | Title | Focus | Answer Type | -|------|--------------------------------------|-----------------------------------------|----------------------------| -| 4.1 | Empty File Creation | touch + existence check | File basename | -| 4.2 | Single Directory Creation | mkdir + basename confirmation | Directory basename | -| 4.3 | Nested Creation With -p | mkdir -p chain | Integer (count created) | -| 4.4 | Manual File Cleanup | rm selected extensions | Integer (remaining .txt) | -| 4.5 | Non-Empty Directory Removal Attempt | rmdir failure reasoning | File basename (survivor) | -| 4.6 | Project Scaffold Build | Extension | Integer (directories made) | -| 4.7 | (Optional) Pattern Preview Cleanup | Optional | Ordered list | - -## 6. Detailed Level Specifications - -### Level 4.1 – Empty File Creation -Start: `$WORKSPACE/level-4/staging/` -Task: Create a file `checkpoint.log` using `touch`. Submit its basename WITHOUT extension. -Answer: `checkpoint` -Validation: -- File exists at `staging/checkpoint.log`. -- Extension removed. -Failures: -- Submits `checkpoint.log` → extension reminder. -- File missing → instruct to run `touch checkpoint.log`. -Hints: -1. "Use touch checkpoint.log" -2. "List with ls to confirm creation." -3. "Submit basename: checkpoint" - -### Level 4.2 – Single Directory Creation -Start: `$WORKSPACE/level-4/staging/` -Task: Create directory `reports`. Submit its basename. -Answer: `reports` -Validation: -- Directory exists and is a directory. -Failures: -- Submits full path → ask for basename only. -Hints: -1. "Use the `mkdir` command to create a directory." -2. "After creating it, submit its name: `reports`." - -### Level 4.3 – Nested Creation With -p -Start: `$WORKSPACE/level-4/staging/` -Task: Create nested path `archive/2025/nov/exports/` in one command using `mkdir -p`. Count how many NEW directories were created. -Answer: `4` -Validation: -- Each directory exists. -- Count matches new creations (assume none existed initially). -Failure: -- Uses multiple mkdir calls → acceptable; still count new directories. -- Wrong count → instruct to run `ls -R archive`. -Hints: -1. "Use mkdir -p archive/2025/nov/exports" -2. "List intermediate directories to count them." -3. "Answer: 4" - -### Level 4.4 – Manual File Cleanup -Start: `$WORKSPACE/level-4/cleanup/` -Task: Remove all `.tmp` files manually. After removal, count remaining `.txt` files. -Initial files: `keep.txt`, `old.tmp`, `stray.tmp`, `data.txt` -Action: `rm old.tmp stray.tmp` -Answer: `2` (remaining `.txt` files: keep.txt, data.txt) -Validation: -- `.tmp` files absent. -- Count of `.txt` files is correct. -Failure: -- Leaves a `.tmp` file → "You did not remove all .tmp files." -- Removes a `.txt` file → "You removed a required survivor." -Hints: -1. "List .tmp files before removing." -2. "Remove each with `rm name.tmp`." -3. "Two .txt files should remain." - -### Level 4.5 – Non-Empty Directory Removal Attempt -Start: `$WORKSPACE/level-4/obstacle/` -Task: Attempt to remove `nested/` with `rmdir nested` (it will fail). Identify the basename of the file inside that made it non-empty. -File inside: `token.md` -Answer: `token` -Validation: -- Confirm `nested/` still exists. -- Confirm `token.md` exists. -Failure: -- Removes directory via forceful method (not taught) → Instruct reset. -- Submits `token.md` → extension reminder. -Hints: -1. "Try `rmdir nested` and observe the error." -2. "List the contents of `nested` to see what's inside." -3. "The file's basename is `token`." - -### Level 4.6 – Project Scaffold Build -Start: `$WORKSPACE/level-4/scaffold/` -Task: Build a mini structure: `app/`, with `src/`, `tests/`, and `docs/` inside it. Submit the total number of DIRECTORIES created. -Answer: `4` -Validation: -- All directories exist (`app`, `app/src`, `app/tests`, `app/docs`). -- Count includes the root `app`. -Failure: -- Missing one subdirectory → instruct to list contents. -Hints: -1. "Create the root `app` directory first, then `cd` into it." -2. "Use `mkdir` to create `src`, `tests`, and `docs`." -3. "The total number of directories created is 4." - -### Level 4.7 – (Optional) Pattern Preview Cleanup -Start: `$WORKSPACE/level-4/scaffold/` -Setup (game engine adds before level begins): -``` -logs/ -├── a.log -├── b.log -├── c.tmp -└── readme.md -``` -Task: Remove only `.log` files. Submit an ordered comma-separated list of remaining basenames (alphabetic). -Remaining files after correct removal: `c.tmp`, `readme.md` -Answer: `c,readme` -Validation: -- Log files absent. -- Survivors present in correct order (alphabetic by basename). -Failure: -- Removes `c.tmp` or `readme.md` → "Removed required survivor." -- Leaves a `.log` → "A .log file still remains." -Hints: -1. "Identify .log files with `ls`." -2. "Remove them one by one with `rm`." -3. "The remaining basenames, sorted alphabetically, are `c,readme`." - -## 7. General Validation Rules -- Trim whitespace. -- File basename answers: remove final extension segment after last dot. -- Directory basename answers: no slashes. -- Integer answers: strict digit parsing. -- Ordered list answers: split by commas, trim spaces, enforce expected sequence. -- Location-sensitive tasks: verify current `pwd` starts with `$WORKSPACE/level-4/...`. -- For creation counts: engine records pre-state to ensure accurate "new" count. - -## 8. Hint Strategy -Three hints per level: -1. Command reminder / orientation. -2. Method refinement (-p usage, manual enumeration). -3. Near-answer or explicit value for reinforcement. -Track `attempts` and `hints_used` for telemetry. - -## 9. Telemetry / State Logging -Per completion: -``` -"4.n": { - "time_sec": <int>, - "attempts": <int>, - "hints": <int> -} -``` -Advancement: Levels 4.1–4.5 required. 4.6 Extension. 4.7 Optional. - -## 10. Failure Message Templates -- Extension included: "Submit basename only (remove extension)." -- Missing file after touch: "File not found—did you create it with `touch`?" -- Directory missing: "Directory not found—verify with `ls`." -- Non-empty removal confusion: "`rmdir` only works on empty directories—inspect contents." -- Incorrect count: "Count does not match existing items—list them again." -- Survivor removed: "You removed a required file—rebuild or reset." - -## 11. Edge Cases & Robustness -- If a student force-deletes a non-empty directory (e.g., using `rm -r` prematurely), instruct to reset and clarify that recursive removal is out of scope. -- Accidental re-running of `mkdir -p` does not inflate count (engine compares existing vs newly created). -- Pattern preview (optional) must not require glob knowledge; hints mention manual removal first. - -## 12. Implementation Checklist -- Provide helper functions for file/dir existence, listing, and counting. -- Validate current directory when location is critical. -- Flag optional level in metadata. -- Implement a reset mechanism to reconstruct the full Section 4 tree. - -## 13. Advancement Criteria -After Level 4.5 success: -- `current_level = "5.1"` -Optional Level 4.7 accessible post-advancement. - -## 14. Sample Instruction Screen (Level 4.3) -``` -══════════════════════════════════════════════════════ -LEVEL 4.3: Nested Creation With -p - -Goal: -Create a nested directory path archive/2025/nov/exports -in ONE command. - -Remember: -`mkdir -p` will create all missing parent directories. - -Task: -1. Run `mkdir -p archive/2025/nov/exports` -2. Verify structure with `ls -R archive` -3. Count how many directories you just created -4. Submit that number - -Submit with: shellgame submit <number> -Need help? Type: shellgame hint -══════════════════════════════════════════════════════ -``` - -## 15. Pedagogical Reinforcement Points -- Emphasizes intentional environment shaping over random creation. -- Introduces “failure by design” (non-empty directory removal) as a learning pattern. -- Encourages enumeration and structural thinking (scaffold building). -- Prepares learner for more complex manipulations in later sections. - -## 16. Future Cross-References -- Section 5: File Inspection (`ls -l`, `file`). -- Section 6: Copying & Moving (`cp`, `mv`). -- Section 9: Wildcards—will generalize pattern-based cleanup beyond manual enumeration. -- Recursive removal (`rm -r`) is intentionally delayed for focus and safety. - -## 17. Summary (Instructor View) -Section 4 transitions learners from consumers of a prepared filesystem to active shapers. The exercises calibrate risk-free creation and deliberate cleanup, strengthening internalization of directory semantics ("empty" vs "non-empty") while reinforcing navigation fluency. By the end, students possess practical confidence to build and tidy structures—a critical foundation for subsequent wildcard operations and advanced manipulations. - -Pacing Note: Defer scaffold (4.6) if time-constrained; essentials are creation/removal fundamentals (4.1–4.5). - -End of Section 4 Specification. \ No newline at end of file diff --git a/docs/specs/sections/section5.md b/docs/specs/sections/section5.md deleted file mode 100644 index 08d7acb..0000000 --- a/docs/specs/sections/section5.md +++ /dev/null @@ -1,240 +0,0 @@ -# Section 5 Specification – "Zkoumání souborů" (aktuální implementace) - -## 1. Purpose & Scope -Section 5 introduces inspection of file metadata and type identification, building on navigation and basic file interaction skills from Sections 1–4. Focus areas: -- Understanding `ls -l` output format (permissions, size, modification time) -- Reading file sizes and identifying files by size -- Using the `file` command to identify file types beyond extensions -- Distinguishing text from binary files -- Recognizing executables and scripts by metadata - -Deliberate exclusions: -- No file copying (`cp`) or moving (`mv`) — deferred to Section 6 -- No permission modification (`chmod`) — covered in Section 7 -- No advanced time formats or sorting -- No ownership changes -- No pattern matching or wildcards yet - -Allowed commands (typicky): `pwd`, `ls`, `ls -l`, `ls -lh`, `cd`, `cat`, `file`, `less` - -Estimated Time: ~8–15 minutes (podle toho, zda student dělá rozšiřující/volitelné levely). - -Poznámka: Tento dokument popisuje aktuální implementaci v `src/shellgame/levels/sections/section5.py`. - -## 2. Learning Objectives -By the end of Section 5 the player will: -1. Parse `ls -l` output to extract: permissions string, size in bytes, and filename. -2. Identify a specific file by its exact byte size. -3. Use the `file` command to determine the actual file type, regardless of its extension. -4. Distinguish between text files, scripts, executables, and data files. -5. Locate an executable file by recognizing the `x` permission bit. -6. Find a "disguised" file (e.g., a script with a misleading extension). -7. (Optional) Count files within a specific size range using manual inspection. - -## 3. Concept Tutorial (Displayed Before Level 5.1) -Key concepts: -- `ls -l` displays "long format" with columns: - - Column 1: permissions (10 characters: type + owner/group/other) - - Column 5: size in bytes - - Final column: filename -- File size is measured in bytes. -- `file <name>` inspects file content to determine its type (it ignores the extension). -- Executable permission: look for an `x` in the permissions string. -- Extensions can lie; `file` reveals the truth. - -Example `ls -l` output: -``` --rw-r--r-- 1 user group 1024 Nov 14 10:23 data.txt --rwxr-xr-x 1 user group 4096 Nov 13 09:15 script.sh -drwxr-xr-x 2 user group 4096 Nov 12 14:30 folder -``` - -Short prompt: -"Learn to read file metadata. Size, type, and permissions tell the full story—extensions are just hints." - -## 4. Directory Layout (aktuální) -Base: `$WORKSPACE/level-5/` - -Aktuální struktura (zjednodušeně): -``` -level-5/ -├── sizes/ -│ └── database.db (12345 bytes) -├── search/ -│ ├── file_a (1000 bytes) -│ ├── file_b (2000 bytes) -│ ├── file_c (1338 bytes) -│ └── file_d (1337 bytes) -├── types/ -│ ├── file1 (text) -│ ├── file2 (binary) -│ └── file3 (JPEG header) -├── logs/ -│ └── server.log (200 lines; one has CRITICAL ... Code 42) -├── downloads/ (Extension) -│ ├── photo1.jpg (JPEG header) -│ ├── photo2.jpg (JPEG header) -│ └── secret.jpg (text) -├── bin/ (Optional) -│ ├── run.sh -│ ├── readme.txt -│ ├── calc.py (python script) -│ └── program (ELF header) -└── mystery/ (Summary) - ├── data.bin (text) - ├── notes.xyz (text) - ├── config.txt (PNG header) - ├── analyzer.dat (python script) - └── readme.doc (ELF header) -``` - -## 5. Level Index (aktuální) - -Legenda: -- **Core** = nutné pro postup -- **Extension** = rozšiřující (není nutné) -- **Optional** = volitelné - -| ID | Title | Focus | Answer Type | Notes | -|-----|-------|-------|-------------|-------| -| 5.0 | Intro | čtení úvodu | — | auto-pass | -| 5.1 | Velikost souboru | `ls -l` a velikost v bajtech | integer | Core | -| 5.2 | Hledání podle velikosti | najít soubor s 1337 bajty | filename | Core | -| 5.3 | Typ souboru | `file` a "přípona nelže" | filename | Core | -| 5.4 | less + vyhledávání | práce s velkým logem | integer | Core | -| 5.5 | Zamaskovaný soubor | falešné `.jpg` | filename | Extension | -| 5.6 | Python skript | rozpoznání skriptu v `bin/` | filename | Optional | -| 5.7 | Souhrn Sekce 5 | kombinace `file *` a počítání typů | `text,img,script` | Core | - -## 6. Detailed Level Specifications - -### Level 5.1 – Velikost souboru -Start: `$WORKSPACE/level-5/sizes/` -Task: "Zjistěte přesnou velikost souboru `database.db` v bajtech." -Answer: integer (např. `12345`) -Hints: -1. Připomenutí `ls -l`. -2. "velikost je pátý sloupec". -3. "použijte `ls -l database.db`". - -### Level 5.2 – Hledání podle velikosti -Start: `$WORKSPACE/level-5/search/` -Task: "Najděte soubor, který má přesně 1337 bajtů."\ -Answer: filename (např. `file_d`) -Hints: -1. "Use `ls -l` to check sizes." -2. "Look for `1337` in the size column." -3. "The answer is `large`." - -### Level 5.3 – Typ souboru -Start: `$WORKSPACE/level-5/types/` -Task: "Ze tří souborů bez přípony zjistěte, který je JPEG (pomocí `file`)."\ -Answer: filename (např. `file3`) -Hints: -1. "Run: `file image.jpg`" -2. "Read the output; take the first word after the colon." -3. "The answer is `ASCII`." - -### Level 5.4 – less (log) -Start: `$WORKSPACE/level-5/logs/` -Task: "Najděte v `server.log` řádek s `CRITICAL` a odevzdejte číslo na konci."\ -Answer: integer (např. `42`) -Hints: -1. "Run `file` on each file in the directory." -2. "Look for 'shell script' or 'bash' in the output for a `.txt` file." -3. "The answer is `notes`." - -### Level 5.5 – Zamaskovaný soubor (Extension) -Start: `$WORKSPACE/level-5/downloads/` -Task: "Najděte `.jpg`, který je ve skutečnosti text (ASCII text) pomocí `file *.jpg`."\ -Answer: filename (např. `secret.jpg`) - -### Level 5.6 – Python skript (Optional) -Start: `$WORKSPACE/level-5/bin/` -Task: "Najděte soubor, který je Python skript (pomocí `file *`)."\ -Answer: filename (např. `calc.py`) -Hints: -1. "Use `ls -l` and look at the first column of 10 characters." -2. "Executable files have an 'x' in their permissions string, like `-rwx------`." -3. "The answer is `mystery`." - -## 7. General Validation Rules -- Trim whitespace from answers. -- File basename answers should not include the extension. -- Single word answers (file types) are case-insensitive. -- Size validation requires an exact byte match. - -## 8. Hint Strategy -Three hints per level: -1. Command introduction / reminder. -2. Column or output interpretation guidance. -3. Near-answer or explicit answer. - -## 9. Telemetry / State Logging -Per completion: -``` -"5.n": { - "time_sec": <int>, - "attempts": <int>, - "hints": <int> -} -``` -Advancement: Levels 5.1–5.3 required. 5.4 Extension. 5.5 Optional. - -## 10. Failure Message Templates (doporučení) -- "Odpověď musí být číslo." / "Očekáváno celé číslo" -- "Zadejte název souboru (bez cesty)." -- "Zkontrolujte výstup `ls -l` / `file` a zkuste to znovu." - -## 11. Implementation Checklist -- Pre-create all files with exact byte sizes and content. -- Ensure the `file` command is available. -- Implement helper functions for getting file size, type, and permissions. -- Implement a reset mechanism for the Section 5 directory tree. - -## 12. Advancement Criteria -Aktuální implementace postupuje lineárně podle registry (včetně Extension/Optional dle toho, jak je registry interpretuje). - -Poznámka: Pokud chceme, aby Extension/Optional nikdy neblokovaly postup, musí to vynutit logika v `levels/registry.py` (mimo rozsah tohoto dokumentu). - -## 13. Sample Instruction Screen (Level 5.3) -``` -══════════════════════════════════════════════════════ -LEVEL 5.3: File Type Detection - -Extensions can be misleading. The 'file' command inspects -actual content to determine the true file type. - -Task: -1. Navigate to `level-5/types/` -2. Run: `file image.jpg` -3. Read the output after the colon. -4. Submit the FIRST word of the type description. - -Example output: "image.jpg: ASCII text" -You would submit: ASCII - -Submit with: shellgame submit <type-word> -Need help? Type: shellgame hint -══════════════════════════════════════════════════════ -``` - -## 14. Pedagogical Reinforcement Points -- Emphasizes metadata over assumptions (extension ≠ type). -- Introduces structured output parsing (`ls -l` columns). -- Reinforces byte-level size awareness. -- Executable recognition prepares for the permissions section. -- The `file` command demystifies the binary vs. text distinction. - -## 15. Future Cross-References -- **Section 6 (Copying & Moving)**: Will use these inspection skills before acting on files. -- **Section 7 (Permissions)**: Builds directly on executable recognition. -- **Section 8 (Content & Redirection)**: Will create files and verify their sizes. -- **Section 9 (Wildcards)**: Will filter files by size/type patterns. - -## 16. Summary (Instructor View) -Section 5 transitions students from spatial navigation to attribute-aware inspection. Students learn to interrogate files beyond their names, establishing a diagnostic mindset essential for system administration and debugging. Metadata literacy enables confident manipulation in subsequent sections. - -Time Note: Focus early on size + type (5.1–5.3); disguises and executables enrich after mastery. - -End of Section 5 Specification. \ No newline at end of file diff --git a/docs/specs/sections/section6.md b/docs/specs/sections/section6.md deleted file mode 100644 index 36fc1aa..0000000 --- a/docs/specs/sections/section6.md +++ /dev/null @@ -1,232 +0,0 @@ -# Section 6 Specification – "Copying & Moving Files" - -## 1. Purpose & Scope -Section 6 introduces file and directory manipulation through copying and moving operations. Building on inspection skills from Section 5, students learn to: -- Copy files with `cp` -- Copy directories recursively with `cp -r` -- Move/rename files and directories with `mv` -- Understand the difference between copy (duplicate) and move (relocate/rename) -- Recognize when operations overwrite existing files -- Plan multi-step file organization workflows - -Deliberate exclusions: -- No deletion (`rm`, `rmdir`) as that was covered in Section 4. -- No permission preservation flags (`-p`). -- No interactive prompts (`-i`). -- No wildcards yet (deferred to Section 9). -- No symbolic links. - -Allowed commands: `pwd`, `ls`, `ls -l`, `cd`, `cp`, `cp -r`, `mv`, `cat`, `file` -Estimated Time: 10 minutes (core Levels 6.1–6.5) + optional Level 6.6 (~2 minutes) - -> Time Calibration: Target 8 minutes average; slow path 6 minutes (Levels 6.1–6.4). Level 6.5 becomes Extension (directory rename), 6.6 Optional (multi-step workflow). - -## 2. Learning Objectives -By the end of Section 6 the player will: -1. Copy a single file to a new location with `cp source dest`. -2. Understand that copying creates a duplicate (the original remains). -3. Copy an entire directory tree using `cp -r`. -4. Move or rename a file with `mv source dest`. -5. Recognize that moving removes the original. -6. Rename a directory using `mv`. -7. Organize files into directories using planned copy/move sequences. -8. Count files after operations to verify correct outcomes. -9. (Optional) Execute a multi-step reorganization workflow. - -## 3. Concept Tutorial (Displayed Before Level 6.1) -Key concepts: -- `cp source destination` → creates a copy of `source` at `destination`. Both files exist after. -- `cp -r source_dir dest_dir` → recursively copies a directory and its contents. -- `mv source destination` → moves (or renames) `source` to `destination`. The source is gone after. -- `mv` works for both files and directories without a `-r` flag. -- If `source` and `destination` are in the same directory, `mv` renames the item. -- Overwriting: If a destination file already exists, it gets replaced without warning. - -Visual example: -``` -Before: workspace/file.txt -After `cp file.txt backup.txt`: - workspace/file.txt (original) - workspace/backup.txt (copy) - -After `mv file.txt archive/`: - archive/file.txt (moved) - workspace/ (file.txt is gone) -``` - -Short prompt: -"Copy duplicates. Move relocates. Plan your operations, then verify with `ls`." - -## 4. Directory Layout (Initial for Section 6) -Base: `$WORKSPACE/level-6/` - -Proposed structure: -``` -level-6/ -├── source/ -│ ├── document.txt -│ ├── report.md -│ ├── data.csv -│ └── notes/ -│ ├── todo.txt -│ └── ideas.md -├── backup/ -│ └── .placeholder -├── archive/ -│ └── old.log -├── organize/ -│ ├── photo1.jpg -│ ├── photo2.jpg -│ ├── script.sh -│ ├── script.py -│ ├── readme.txt -│ └── manual.pdf -└── workspace/ - └── .placeholder -``` - -## 5. Level Index -| ID | Title | Focus | Answer Type | -|------|------------------------------------|----------------------------------------|-----------------------| -| 6.1 | Simple File Copy | `cp` single file | Integer (count) | -| 6.2 | Directory Copy | `cp -r` directory tree | Integer (count) | -| 6.3 | File Rename with `mv` | `mv` in same directory (rename) | File basename | -| 6.4 | File Move | `mv` to different directory | Integer (count) | -| 6.5 | Directory Rename | Extension | Directory basename | -| 6.6 | (Optional) Multi-Step Organization | Optional | Ordered list | - -## 6. Detailed Level Specifications - -### Level 6.1 – Simple File Copy -Start: `$WORKSPACE/level-6/source/` -Task: "Copy `document.txt` to the `backup/` directory. After copying, count the total number of `.txt` files that exist across BOTH `source/` and `backup/`. Submit the count." -Answer: `2` -Hints: -1. "Use: `cp document.txt ../backup/`" -2. "After copying, the original still exists. Check both directories." -3. "The total count should be 2." - -### Level 6.2 – Directory Copy -Start: `$WORKSPACE/level-6/source/` -Task: "Copy the entire `notes/` directory to `workspace/` using `cp -r`. Count the total number of files inside `workspace/notes/` after copying. Submit the count." -Answer: `2` -Hints: -1. "Use: `cp -r notes ../workspace/`" -2. "The `-r` flag is required to copy directories recursively." -3. "Count the files inside the new `workspace/notes/` directory. There should be 2." - -### Level 6.3 – File Rename with `mv` -Start: `$WORKSPACE/level-6/source/` -Task: "Rename `report.md` to `final_report.md`. Submit the new basename without the extension." -Answer: `final_report` -Hints: -1. "Use: `mv report.md final_report.md`" -2. "Using `mv` on two files in the same directory results in a rename." -3. "The answer is `final_report`." - -### Level 6.4 – File Move -Start: `$WORKSPACE/level-6/source/` -Task: "Move `data.csv` to the `archive/` directory. After moving, count how many `.csv` files remain in `source/`. Submit the count." -Answer: `0` -Hints: -1. "Use: `mv data.csv ../archive/`" -2. "After a move, the file is gone from the original location." -3. "The count of `.csv` files remaining in `source/` is 0." - -### Level 6.5 – Directory Rename -Start: `$WORKSPACE/level-6/` -Task: "Rename the `workspace/` directory to `projects/`. Submit the new directory basename." -Answer: `projects` -Hints: -1. "Use: `mv workspace projects`" -2. "`mv` works on directories without needing a `-r` flag." -3. "The answer is `projects`." - -### Level 6.6 – (Optional) Multi-Step Organization -Start: `$WORKSPACE/level-6/organize/` -Task: "Organize files by type. First, create directories: `images/`, `scripts/`, and `docs/`. Then, move `photo1.jpg` and `photo2.jpg` to `images/`, `script.sh` and `script.py` to `scripts/`, and `readme.txt` and `manual.pdf` to `docs/`. After organizing, submit a comma-separated list of the new directory names that contain exactly 2 files (alphabetically ordered)." -Answer: `docs,images,scripts` -Hints: -1. "Create the directories first with `mkdir`." -2. "Use `mv` to move each file into its correct new directory." -3. "All three directories should have 2 files. The answer is `docs,images,scripts`." - -## 7. General Validation Rules -- Trim whitespace from answers. -- File/directory basename answers should not include extensions where specified. -- Existence checks: verify source is removed for `mv` and preserved for `cp`. - -## 8. Hint Strategy -Three hints per level: -1. Command syntax reminder. -2. Conceptual clarification (copy vs. move). -3. Explicit answer or verification command. - -## 9. Telemetry / State Logging -Per completion: -``` -"6.n": { - "time_sec": <int>, - "attempts": <int>, - "hints": <int> -} -``` -Advancement: Levels 6.1–6.4 required. 6.5 Extension. 6.6 Optional. - -## 10. Failure Message Templates -- "You copied instead of moved—the original should be gone after `mv`." -- "Destination not found—verify the path." -- "The file count does not match—list files to verify." -- "The directory was copied, not moved/renamed." - -## 11. Implementation Checklist -- Pre-create all source files and directories. -- Implement helper functions for file/dir existence and counting. -- Validation logic must track both source and destination states. -- Implement a reset mechanism for the Section 6 tree. - -## 12. Advancement Criteria -After Level 6.4 success: -- `current_level = "7.1"` -Optional Level 6.6 is accessible post-advancement. - -## 13. Sample Instruction Screen (Level 6.4) -``` -══════════════════════════════════════════════════════ -LEVEL 6.4: File Move - -`mv` relocates a file—it disappears from the original -location and appears at the destination. - -Task: -1. Navigate to `level-6/source/` -2. Move `data.csv` to `../archive/` -3. Verify `data.csv` is GONE from `source/` -4. Count remaining `.csv` files in `source/` -5. Submit that count. - -Command: `mv data.csv ../archive/` - -Submit with: shellgame submit <count> -Need help? Type: shellgame hint -══════════════════════════════════════════════════════ -``` - -## 14. Pedagogical Reinforcement Points -- The copy vs. move distinction is fundamental to file management. -- Recursive copy (`-r`) introduces tree manipulation. -- Renaming as a same-directory move clarifies the mental model. -- Counting exercises verify the success of operations. -- Multi-step organization simulates real-world workflow planning. - -## 15. Future Cross-References -- **Section 7 (Permissions)**: Recognize how permissions are handled during copy operations. -- **Section 8 (Content & Redirection)**: Combine file creation with immediate organization. -- **Section 9 (Wildcards)**: Will enable batch operations like `cp *.txt backup/`. - -## 16. Summary (Instructor View) -Section 6 empowers students to reshape their workspace intentionally. The copy/move duality solidifies their understanding of filesystem state transitions (duplication vs. relocation). By the end, students can confidently reorganize files and verify outcomes—a critical skill for project management and system maintenance. - -Pacing Note: Essentials = copy vs move semantics (6.1–6.4); rename and workflow after advancement. - -End of Section 6 Specification. \ No newline at end of file diff --git a/docs/specs/sections/section7.md b/docs/specs/sections/section7.md deleted file mode 100644 index 88f4b5f..0000000 --- a/docs/specs/sections/section7.md +++ /dev/null @@ -1,361 +0,0 @@ -# Section 7 Specification – "Wildcards & Pattern Matching" - -> **Renumbered.** This section used to be number 10; wildcards were moved ahead of -> permissions and redirection so globbing is taught before it is used. Level IDs and -> `level-10/` workspace paths below still use the old `10.x` numbering, and the -> Extension/Optional level statuses were never implemented. Treat this file as the -> original design intent, not as a description of the shipped section. - -## 1. Purpose & Scope -Section 10 introduces shell globbing (wildcards) for batch file operations. Building on all prior sections, students learn to: -- Use `*` (match any characters) -- Use `?` (match single character) -- Use `[...]` (character classes) -- Use `[!...]` (negated character classes) -- Apply wildcards with commands learned previously (ls, cp, mv, rm) -- Understand glob expansion happens before command execution -- Recognize when patterns match nothing (literal interpretation) -- Combine wildcards with redirection and pipes - -Deliberate exclusions: -- No extended globs (`**`, `?(pattern)`, etc.) -- No brace expansion (`{a,b,c}`) -- No regular expressions (grep regex covered minimally) -- No find command (deferred to Section 11) - -Allowed commands: All previous commands plus wildcard patterns: `*`, `?`, `[abc]`, `[!abc]`, `[a-z]` - -**Shell requirement:** Exercises using `?`, bracket sets, or ranges require -Bash (currently 10.2–10.4). Display this requirement before those commands. -Fish players run the provided quoted `bash -c 'command'` example, then submit -from their existing wrapped shell. Do not switch them into an unwrapped -interactive shell. Exercises using only `*` remain portable. - -Estimated Time: 10–12 minutes (core Levels 10.1–10.6) + optional Level 10.7 (~2 minutes) -<!-- REVISED: --> -> Time Calibration: Target 7 minutes average; slow path 5–6 minutes (Levels 10.1–10.4). 10.5–10.6 become Extension (operations), 10.7 Optional. - -## 2. Learning Objectives -By the end of Section 10 the player will: -1. Use `*` to match zero or more characters. -2. Use `?` to match exactly one character. -3. Use `[abc]` to match one character from a set. -4. Use `[!abc]` or `[^abc]` to match one character NOT in a set. -5. Use ranges like `[0-9]` or `[a-z]`. -6. Apply wildcards to list, copy, move, and remove multiple files at once. -7. Understand that the shell expands globs before passing to commands. -8. Count matches using wildcard patterns. -9. (Optional) Combine complex patterns for precise batch operations. - -## 3. Concept Tutorial (Displayed Before Level 10.1) -Key concepts: -- **Wildcards (globs)**: Patterns the shell expands to matching filenames -- **`*`**: Matches zero or more characters - - `*.txt` matches all files ending in .txt - - `log_*` matches all files starting with log_ - - `*` matches all non-hidden files (no leading dot) -- **`?`**: Matches exactly one character - - `file?.txt` matches file1.txt, fileA.txt but not file12.txt - - `data_?.csv` matches data_1.csv through data_9.csv -- **`[...]`**: Character class—matches one character from set - - `file[123].txt` matches file1.txt, file2.txt, file3.txt - - `[aeiou]*` matches files starting with vowels - - `[0-9]` matches single digit - - `[a-z]` matches single lowercase letter -- **`[!...]`** or **`[^...]`**: Negated class—matches one character NOT in set - - `[!0-9]*` matches files NOT starting with digit -- **Expansion order**: Shell expands globs BEFORE running command - - `ls *.txt` → shell finds matches → runs `ls file1.txt file2.txt ...` -- **No matches**: If pattern matches nothing, often treated as literal string (varies by shell) - -Visual example: -``` -Files: data_1.csv, data_2.csv, image.jpg, readme.txt, script.sh - -ls *.csv → data_1.csv data_2.csv -ls data_?.csv → data_1.csv data_2.csv -ls [dr]* → data_1.csv data_2.csv readme.txt -ls *.[st]* → readme.txt script.sh -``` - -Short prompt: -"Wildcards multiply your power. One pattern, many files. Master globs to work at scale." - -## 4. Directory Layout (Initial for Section 10) -Base: `$WORKSPACE/level-10/` - -Proposed structure: -``` -level-10/ -├── patterns/ -│ ├── file1.txt -│ ├── file2.txt -│ ├── file3.txt -│ ├── file10.txt -│ ├── data_a.csv -│ ├── data_b.csv -│ ├── data_c.csv -│ ├── image1.jpg -│ ├── image2.jpg -│ ├── script.sh -│ ├── readme.md -│ └── archive.tar -├── single/ -│ ├── log_1.txt -│ ├── log_2.txt -│ ├── log_3.txt -│ ├── log_a.txt -│ ├── log_b.txt -│ └── summary.txt -├── classes/ -│ ├── alpha.txt -│ ├── beta.txt -│ ├── gamma.txt -│ ├── delta.txt -│ ├── 1_report.txt -│ ├── 2_report.txt -│ ├── 3_report.txt -│ └── summary.log -├── batch/ -│ ├── temp1.tmp -│ ├── temp2.tmp -│ ├── temp3.tmp -│ ├── keep1.txt -│ ├── keep2.txt -│ └── important.doc -├── organize/ -│ └── .placeholder -└── advanced/ - ├── a1.dat - ├── a2.dat - ├── b1.dat - ├── b2.dat - ├── c1.dat - ├── x.dat - └── y.dat -``` - -## 5. Level Index -| ID | Title | Focus | Answer Type | -|-------|------------------------------------|----------------------------------------|-----------------------| -| 10.1 | Star Wildcard Basics | * for multiple matches | Integer (count) | -| 10.2 | Question Mark Single Match | ? for single character | Integer (count) | -| 10.3 | Character Classes | [abc] sets | Integer (count) | -| 10.4 | Negated Character Classes | [!abc] exclusion | Integer (count) | -| 10.5 | Batch File Operations | Extension | Integer (count) | -| 10.6 | Pattern-Based Cleanup | Extension | Integer (remaining) | -| 10.7 | (Optional) Complex Pattern Combo | Optional | Ordered list | - -## 6. Detailed Level Specifications - -### Level 10.1 – Star Wildcard Basics -Start: `$WORKSPACE/level-10/patterns/` -Task: "Count how many files match the pattern *.txt using ls *.txt | wc -l. Submit the count." -Files matching: file1.txt, file2.txt, file3.txt, file10.txt (4 files) -Answer: `4` -Validation: -- Integer. -- Matches actual *.txt count. -Hints: -1. "Use: ls *.txt | wc -l" -2. "* matches zero or more characters." -3. "Answer: 4" -Failure: -- Wrong count → "Verify with ls *.txt" - -### Level 10.2 – Question Mark Single Match -Start: `$WORKSPACE/level-10/single/` -Task: "Count files matching log_?.txt (single character between underscore and dot). Submit the count." -Files matching: log_1.txt, log_2.txt, log_3.txt, log_a.txt, log_b.txt (5 files) -NOT matching: summary.txt (doesn't match pattern) -Answer: `5` -Validation: -- Integer. -- ? matches exactly one character. -Hints: -1. "Use: ls log_?.txt | wc -l" -2. "? matches exactly one character." -3. "Answer: 5" -Failure: -- Includes summary.txt → "Pattern is log_?.txt—summary doesn't match." - -### Level 10.3 – Character Classes -Start: `$WORKSPACE/level-10/classes/` -Task: "Count files starting with vowels (a, e, i, o, u) using pattern [aeiou]*.txt. Submit the count." -Files matching: alpha.txt (1 file) -NOT matching: beta.txt, gamma.txt, delta.txt (start with consonants) -Answer: `1` -Validation: -- Integer. -- Only files starting with vowels. -Hints: -1. "Use: ls [aeiou]*.txt | wc -l" -2. "Character class [aeiou] matches one vowel." -3. "Answer: 1" -Failure: -- Includes consonants → "Only vowels: a, e, i, o, u" - -### Level 10.4 – Negated Character Classes -Start: `$WORKSPACE/level-10/classes/` -Task: "Count .txt files NOT starting with a digit using [!0-9]*.txt. Submit the count." -Files matching: alpha.txt, beta.txt, gamma.txt, delta.txt (4 files) -NOT matching: 1_report.txt, 2_report.txt, 3_report.txt -Answer: `4` -Validation: -- Integer. -- Excludes files starting with digits. -Hints: -1. "Use: ls [!0-9]*.txt | wc -l" -2. "[!0-9] excludes digits." -3. "Answer: 4" -Failure: -- Includes digit-starting files → "Pattern excludes digits 0-9." - -### Level 10.5 – Batch File Operations -Start: `$WORKSPACE/level-10/batch/` -Task: "Copy all .txt files to ../organize/ using cp *.txt ../organize/. Count how many files are now in organize/. Submit the count." -Files to copy: keep1.txt, keep2.txt (2 files) -Answer: `2` -Validation: -- Files exist in organize/. -- Count matches. -Hints: -1. "Use: cp *.txt ../organize/" -2. "Then: ls ../organize/ | wc -l" -3. "Answer: 2" -Failure: -- Files not copied → "Did you use cp *.txt?" -- Wrong count → "Verify with ls ../organize/" - -### Level 10.6 – Pattern-Based Cleanup -Start: `$WORKSPACE/level-10/batch/` -Task: "Remove all .tmp files using rm *.tmp. Count remaining files in current directory. Submit the count." -Files to remove: temp1.tmp, temp2.tmp, temp3.tmp (3 files) -Remaining: keep1.txt, keep2.txt, important.doc (3 files, or 2 if .txt files were copied) -Answer: `3` (if run before 10.5) or adjust based on state -Validation: -- No .tmp files remain. -- Count correct. -Hints: -1. "Use: rm *.tmp" -2. "Count remaining: ls | wc -l" -3. "Answer: 3" -Failure: -- .tmp files still present → "Use rm *.tmp to remove them." - -### Level 10.7 – (Optional) Complex Pattern Combo -Start: `$WORKSPACE/level-10/advanced/` -Task: "List files matching [ab][12].dat (first char a or b, second char 1 or 2, extension .dat). Submit comma-separated sorted list of basenames without extensions." -Files matching: a1.dat, a2.dat, b1.dat, b2.dat -NOT matching: c1.dat, x.dat, y.dat -Answer: `a1,a2,b1,b2` -Validation: -- Ordered alphabetically. -- Extensions stripped. -- All matches included. -Hints: -1. "Use: ls [ab][12].dat" -2. "Pattern has two character classes." -3. "Answer: a1,a2,b1,b2" -Optional metadata: `optional=true` - -## 7. General Validation Rules (Section 10) -- Trim whitespace. -- Integer answers: strict numeric parsing. -- File basename answers: strip extension. -- Ordered lists: comma-separated, alphabetically sorted. -- Verify wildcards expand correctly before validation. -- Count files after operations to ensure correct behavior. - -## 8. Hint Strategy -Three hints per level: -1. Wildcard syntax and example. -2. Explanation of pattern matching behavior. -3. Explicit answer or verification command. -Track `attempts` and `hints_used`. - -## 9. Telemetry / State Logging -Per completion: -``` -"10.n": { - "time_sec": <int>, - "attempts": <int>, - "hints": <int> -} -``` -Advancement: Levels 10.1–10.4 required. 10.5–10.6 Extension. 10.7 Optional. - -## 10. Failure Message Templates -- Wrong count: "Count does not match—verify pattern." -- Pattern too broad: "Your pattern matched too many files." -- Pattern too narrow: "Your pattern matched too few files." -- Files not removed: "Target files still present—verify rm command." -- Files not copied: "Files not found in destination—verify cp command." -- List not ordered: "List must be alphabetically sorted." -- Extension included: "Remove extensions from basenames." - -## 11. Edge Cases & Robustness -- Hidden files: `*` does NOT match files starting with dot (use `.*` explicitly). -- No matches: behavior varies (some shells pass literal pattern, others error). -- Quote protection: quoted patterns not expanded (teach: use unquoted). -- Case sensitivity: patterns are case-sensitive. -- Space in filenames: wildcards handle spaces (each match treated separately). - -## 12. Implementation Checklist -- Pre-create all files with predictable names. -- Provide helpers: - - `glob_expand(pattern, directory)` → list of matches - - `count_matches(pattern, directory)` → int - - `file_exists(path)` → bool - - `strip_extension(filename)` → string -- Validate glob expansion matches expectations. -- Reset mechanism reconstructs Section 10 tree. -- Track state changes from operations (cp, mv, rm). - -## 13. Advancement Criteria -After Level 10.4 success: -- `current_level = "11.1"` -Optional Level 10.7 accessible post-advancement. - -## 14. Sample Instruction Screen (Level 10.3) -``` -══════════════════════════════════════════════════════ -LEVEL 10.3: Character Classes - -Character classes let you match one character from a set. -[aeiou] matches any single vowel. - -Task: -1. Navigate to level-10/classes/ -2. Count .txt files starting with vowels -3. Use pattern: [aeiou]*.txt -4. Command: ls [aeiou]*.txt | wc -l -5. Submit the count - -Remember: [set] matches ONE character from the set. - -Submit with: shellgame submit <count> -Need help? Type: shellgame hint -══════════════════════════════════════════════════════ -``` - -## 15. Pedagogical Reinforcement Points -- Wildcards enable batch operations without scripting. -- Pattern precision matters: too broad or narrow affects results. -- Shell expansion happens before command sees arguments. -- Character classes provide fine-grained control. -- Negation ([!...]) teaches inverse matching logic. -- Practical application: cleanup, organization, filtering at scale. - -## 16. Future Cross-References -- Section 11 (find): more powerful searching beyond simple globs. -- Scripting: loops over wildcard matches. -- Regular expressions (grep): similar but more powerful pattern language. -- Advanced shells: extended globs, brace expansion. - -## 17. Summary (Instructor View) -Section 10 transforms students from operating on single files to manipulating file sets with patterns. Wildcard literacy is fundamental to efficient command-line work—enabling rapid filtering, batch processing, and pattern-based organization. By mastering globs, students gain the multiplier effect essential for real-world filesystem management. - -Pacing Note: Core emphasizes pattern comprehension before batch mutation. - -End of Section 10 Specification. \ No newline at end of file diff --git a/docs/specs/sections/section8.md b/docs/specs/sections/section8.md deleted file mode 100644 index 0755d60..0000000 --- a/docs/specs/sections/section8.md +++ /dev/null @@ -1,200 +0,0 @@ -# Section 8 Specification – "Permissions Matter" - -> **Renumbered.** This section used to be number 7; wildcards were moved ahead of -> permissions and redirection so globbing is taught before it is used. Level IDs and -> `level-7/` workspace paths below still use the old `7.x` numbering, and the -> Extension/Optional level statuses were never implemented. Treat this file as the -> original design intent, not as a description of the shipped section. - -## 1. Purpose & Scope -Section 7 introduces file permissions, a fundamental concept in Unix-like systems. Building on file inspection skills from Section 5, students will learn to: -- Read the permission string in `ls -l` output. -- Understand the meaning of read (`r`), write (`w`), and execute (`x`) for owner, group, and others. -- Modify file permissions using `chmod`. -- Make a script executable. -- Protect a file by removing write permissions. - -Deliberate exclusions: -- No advanced permission topics like `setuid`, `setgid`, or sticky bits. -- No `umask` or default permissions. -- No file ownership changes (`chown`, `chgrp`). - -Allowed commands: `pwd`, `ls`, `ls -l`, `cd`, `chmod`, `cat` -Estimated Time: 7 minutes (core Levels 7.1–7.4) -> Time Calibration: Target 6 minutes average; slow path 5 minutes focusing on 7.1, 7.2, 7.4. Level 7.3 (protect & error) becomes Extension (can revisit after numeric mode). - -## 2. Learning Objectives -By the end of Section 7, the player will be able to: -1. Identify the owner, group, and other permissions for a file. -2. Recognize which files are executable by looking at their permissions. -3. Add execute permissions to a file using `chmod +x`. -4. Remove write permissions using `chmod -w`. -5. Apply permissions using numeric modes (e.g., `chmod 755`). -6. Understand the consequence of trying to write to a file without write permission. - -## 3. Concept Tutorial (Displayed Before Level 7.1) -Key concepts: -- **Permissions**: Every file and directory has permissions that control who can read, write, or execute it. -- **`ls -l` Output**: The first 10 characters show the permissions (e.g., `-rwxr-xr--`). - - The first character is the file type (`-` for file, `d` for directory). - - The next 3 are for the **owner** (`rwx` = read, write, execute). - - The next 3 are for the **group** (`r-x` = read, execute). - - The final 3 are for **others** (`r--` = read only). -- **`chmod`**: The command to "change mode" (change permissions). - - **Symbolic mode**: `chmod +x file` (adds execute), `chmod -w file` (removes write). You can specify `u` (user/owner), `g` (group), `o` (other), e.g., `chmod u+x file`. - - **Numeric (octal) mode**: Each permission has a value: `r`=4, `w`=2, `x`=1. Sum them up for each category. `rwx` = 4+2+1=7. `r-x` = 4+0+1=5. `r--` = 4+0+0=4. So, `rwxr-xr--` is `754`. - -Short prompt: -"Permissions control who can do what. Use `ls -l` to see them and `chmod` to change them. Make your scripts runnable and your data safe." - -## 4. Directory Layout (Initial for Section 7) -Base: `$WORKSPACE/level-7/` - -``` -level-7/ -├── scripts/ -│ ├── run_me.sh (permissions: 755, rwxr-xr-x) -│ └── needs_fixing.sh (permissions: 644, rw-r--r--) -├── data/ -│ ├── report.txt (permissions: 666, rw-rw-rw-) -│ └── protected.dat (permissions: 444, r--r--r--) -└── numeric/ - └── target.sh (permissions: 644, rw-r--r--) -``` - -## 5. Level Index -| ID | Title | Focus | Answer Type | -|------|--------------------------------|-------------------------------------|----------------------| -| 7.1 | Find the Executable | Reading `x` permission bit | File basename | -| 7.2 | Make a Script Executable | `chmod +x` | Permission string | -| 7.3 | Protect a File | Extension | Error keyword | -| 7.4 | Apply Numeric Mode | `chmod 755` | Permission string | - -## 6. Detailed Level Specifications - -### Level 7.1 – Find the Executable -Start: `$WORKSPACE/level-7/scripts/` -Task: "One of the scripts in this directory is already executable. Use `ls -l` to find it. Submit its basename without the extension." -Target: `run_me.sh` -Answer: `run_me` -Validation: -- The submitted name must correspond to the file with execute (`x`) permissions. -Hints: -1. "Use `ls -l` to view the permissions for all files." -2. "Look for an `x` in the permission string (e.g., `-rwxr-xr-x`)." -3. "The executable file is `run_me.sh`." - -### Level 7.2 – Make a Script Executable -Start: `$WORKSPACE/level-7/scripts/` -Task: "The script `needs_fixing.sh` is not executable. Add execute permission for the owner (`u`), group (`g`), and others (`o`). After you run the command, submit the new permission string for the owner (the first three letters after the initial dash)." -Action: `chmod +x needs_fixing.sh` or `chmod 755 needs_fixing.sh`. -Initial permissions: `rw-r--r--`. Final permissions: `rwxr-xr-x`. -Owner's permission trio: `rwx`. -Answer: `rwx` -Validation: -- The file `needs_fixing.sh` must have execute permissions for all. -- The submitted answer must be the owner's permission string. -Hints: -1. "Use `chmod +x <filename>` to add execute permission for everyone." -2. "After running `chmod`, use `ls -l` again to see the new permissions." -3. "The owner's permissions will be `rwx`." - -### Level 7.3 – Protect a File -Start: `$WORKSPACE/level-7/data/` -Task: "The file `report.txt` can be written to by anyone. Remove the write permission (`w`) for the 'other' users. Then, try to append text to it with `echo 'test' >> report.txt`. The command will fail. Submit the key word from the error message." -Action: `chmod o-w report.txt`. Then `echo 'test' >> report.txt`. -Error message: `bash: report.txt: Permission denied` -Answer: `denied` -Validation: -- The file `report.txt` must have `o-w` permissions. -- The submitted word must be `denied` (case-insensitive). -Hints: -1. "Use `chmod o-w report.txt` to remove write permission for 'others'." -2. "After changing the permission, try to append to the file: `echo 'test' >> report.txt`." -3. "The error message contains the word `denied`." - -### Level 7.4 – Apply Numeric Mode -Start: `$WORKSPACE/level-7/numeric/` -Task: "Use the numeric mode to set the permissions of `target.sh` to `755` (owner can read/write/execute, group and others can read/execute). After setting it, submit the new permission string for 'other' users (the last three characters)." -Action: `chmod 755 target.sh`. -Final permissions: `rwxr-xr-x`. -"Other" permissions: `r-x`. -Answer: `r-x` -Validation: -- The file `target.sh` must have `755` permissions. -- The submitted answer must be the "other" permission string. -Hints: -1. "Use the command `chmod 755 target.sh`." -2. "Remember, `755` translates to `rwxr-xr-x`." -3. "The last three characters of the permission string are `r-x`." - -## 7. General Validation Rules -- Trim whitespace from answers. -- Permission string answers are case-sensitive. -- Error message keywords are case-insensitive. -- Validation logic will use `stat` or `ls -l` parsing to check the actual file modes on the filesystem. - -## 8. Hint Strategy -1. High-level concept reminder (e.g., "Use `chmod`..."). -2. Specific syntax suggestion (e.g., "`chmod +x ...`"). -3. Explicit answer or verification command. - -## 9. Telemetry / State Logging -Per completion: -``` -"7.n": { - "time_sec": <int>, - "attempts": <int>, - "hints": <int> -} -``` -Advancement: Core levels 7.1, 7.2, 7.4 required. 7.3 Extension. - -## 10. Failure Message Templates -- "That's not the right permission string. Use `ls -l` to double-check." -- "The file does not have the correct permissions yet. Try the `chmod` command again." -- "That's not the keyword from the error message. Make sure you are running the correct command after changing permissions." - -## 11. Implementation Checklist -- Create the initial directory structure and files. -- Use `chmod` in the setup script to set the initial permissions for each level. -- Implement validation helpers that can read and parse file permission strings. -- Implement a reset mechanism for the Section 7 tree. - -## 12. Advancement Criteria -After Level 7.4 success: -- `current_level = "8.1"` - -## 13. Sample Instruction Screen (Level 7.2) -``` -══════════════════════════════════════════════════════ -LEVEL 7.2: Make a Script Executable - -The script `needs_fixing.sh` can't be run because it's -missing the execute permission. - -Task: -1. Use `ls -l` to see the current permissions. -2. Use `chmod +x needs_fixing.sh` to add execute permission. -3. Use `ls -l` again to see the change. -4. Submit the owner's new permission string (e.g., `rwx`). - -Submit with: shellgame submit <permission-string> -Need help? Type: shellgame hint -══════════════════════════════════════════════════════ -``` - -## 14. Pedagogical Reinforcement Points -- Connects the abstract concept of permissions to the practical ability to run a script. -- Demonstrates how permissions provide a layer of safety (preventing accidental writes). -- Introduces both symbolic and numeric modes, as both are common in the real world. - -## 15. Future Cross-References -- **Section 11 (Search & Discovery)**: Extend with `find . -perm 755` queries. - -## 16. Summary (Instructor View) -Section 7 covers the critical topic of file permissions. Students learn not just the theory but the practical application: making scripts runnable and protecting files. This knowledge is essential for any user in a multi-user environment and is a prerequisite for writing and deploying simple shell scripts. - -Time Note: Teach execute + numeric mode first (7.1, 7.2, 7.4). File protection scenario enriches after basics. - -End of Section 7 Specification. \ No newline at end of file diff --git a/docs/specs/sections/section9.md b/docs/specs/sections/section9.md deleted file mode 100644 index 9f105a7..0000000 --- a/docs/specs/sections/section9.md +++ /dev/null @@ -1,170 +0,0 @@ -# Section 9 Specification – "Redirection & Pipes" - -> **Renumbered.** This section used to be number 8; wildcards were moved ahead of -> permissions and redirection so globbing is taught before it is used. Level IDs and -> `level-8/` workspace paths below still use the old `8.x` numbering, and the -> Extension/Optional level statuses were never implemented. Treat this file as the -> original design intent, not as a description of the shipped section. - -## 1. Purpose & Scope -Section 8 introduces output redirection and pipes, fundamental Unix concepts for combining commands and saving output. Building on prior sections, students learn to: -- Redirect stdout to files using `>` -- Append stdout to files using `>>` -- Connect commands using pipes (`|`) -- Use `head` and `tail` to view file portions -- Count lines, words, and characters with `wc` - -Deliberate exclusions: -- No stderr redirection (covered in Section 9) -- No advanced file descriptors -- No process substitution -- No tee command (optional mention only) - -Allowed commands: All previous commands plus `>`, `>>`, `|`, `head`, `tail`, `wc` -Estimated Time: 8–10 minutes (core Levels 8.1–8.5) + optional Level 8.6 (~2 minutes) - -## 2. Learning Objectives -By the end of Section 8 the player will: -1. Redirect command output to a file using `>`. -2. Understand that `>` overwrites existing files. -3. Append output to existing files using `>>`. -4. View the first N lines of a file with `head`. -5. View the last N lines of a file with `tail`. -6. Count lines, words, and characters with `wc`. -7. Combine commands using pipes (`|`). -8. Build simple pipelines to filter and process data. - -## 3. Concept Tutorial (Displayed Before Level 8.1) -Key concepts: -- **Standard output (stdout)**: Where commands normally print their results -- **Redirection `>`**: Send stdout to a file instead of the screen - - `ls > files.txt` saves directory listing to files.txt - - WARNING: Overwrites existing file content! -- **Append `>>`**: Add to end of file without overwriting - - `echo "new line" >> log.txt` -- **Pipes `|`**: Connect output of one command to input of another - - `cat file.txt | wc -l` counts lines in file - - `ls | head -5` shows first 5 items -- **`head`**: Show first N lines (default 10) - - `head -3 file.txt` shows first 3 lines -- **`tail`**: Show last N lines (default 10) - - `tail -5 file.txt` shows last 5 lines -- **`wc`**: Word count utility - - `wc -l` counts lines - - `wc -w` counts words - - `wc -c` counts characters/bytes - -Visual representation: -``` -Command ──── stdout ───▶ Screen (default) - -Command ──── > ────────▶ file.txt (overwrite) - -Command ──── >> ───────▶ file.txt (append) - -Command1 ──── | ───────▶ Command2 ──── | ───▶ Command3 -``` - -## 4. Level Breakdown - -### Level 8.0 – Section Introduction -- Type: Intro (non-interactive) -- Content: Tutorial material from Section 3 above -- Validation: None (auto-advance on Enter) - -### Level 8.1 – Basic Output Redirection -- Goal: Save command output to a file -- Setup: Empty workspace directory -- Task: "Save the output of `ls /` to a file called `root_contents.txt`" -- Expected: `ls / > root_contents.txt` -- Validation: File exists and contains expected content -- Hints: - 1. "Use `>` to redirect output to a file" - 2. "The syntax is: command > filename" - 3. "Try: `ls / > root_contents.txt`" - -### Level 8.2 – Overwrite Warning -- Goal: Understand that `>` overwrites -- Setup: Directory with existing file `data.txt` containing "original content" -- Task: "The file `data.txt` exists. Run `echo 'new content' > data.txt`, then check what happened to the original content." -- Submit: Answer what happened (e.g., "overwritten" or "replaced") -- Validation: String match for overwrite concept -- Hints: - 1. "Use `cat data.txt` to see the file content after redirection" - 2. "Was the original content preserved or replaced?" - -### Level 8.3 – Append to File -- Goal: Use `>>` to preserve existing content -- Setup: File `log.txt` with some entries -- Task: "Add a new line 'Entry 4' to the end of `log.txt` without losing existing entries" -- Expected: `echo "Entry 4" >> log.txt` -- Validation: File contains original content plus new line -- Hints: - 1. "Use `>>` instead of `>` to append" - 2. "Syntax: echo 'text' >> filename" - -### Level 8.4 – Head and Tail -- Goal: View portions of files -- Setup: File `numbers.txt` with lines 1-20 -- Task: "How many is the sum of the first number and last number in `numbers.txt`?" -- Expected: Use `head -1` and `tail -1` to find first and last -- Validation: Integer answer (1 + 20 = 21) -- Hints: - 1. "Use `head -1 numbers.txt` to see the first line" - 2. "Use `tail -1 numbers.txt` to see the last line" - 3. "Add the two numbers together" - -### Level 8.5 – Word Count -- Goal: Use wc to count lines/words/characters -- Setup: File `article.txt` with known line count -- Task: "How many lines are in `article.txt`?" -- Expected: `wc -l article.txt` -- Validation: Integer match -- Hints: - 1. "The `wc` command counts things in files" - 2. "Use `wc -l` to count lines specifically" - 3. "Try: `wc -l article.txt`" - -### Level 8.6 – Simple Pipeline -- Goal: Combine commands with pipes -- Setup: Directory with many files (15+) -- Task: "Count how many items are in the current directory using `ls` and `wc`" -- Expected: `ls | wc -l` -- Validation: Integer match -- Hints: - 1. "Pipes (`|`) connect the output of one command to the input of another" - 2. "First, `ls` lists items. Then `wc -l` counts lines." - 3. "Try: `ls | wc -l`" - -### Level 8.7 – Multi-stage Pipeline (Extension) -- Goal: Build longer pipelines -- Setup: File with data to filter -- Task: "Find how many unique words start with 'a' in `words.txt`" -- Expected: `grep '^a' words.txt | wc -l` or similar -- Validation: Integer match -- Hints: - 1. "You can chain multiple pipes: cmd1 | cmd2 | cmd3" - 2. "Use `grep '^a'` to find lines starting with 'a'" - 3. "Pipe the result to `wc -l` to count" - -## 5. Common Mistakes & Guardrails -- Confusing `>` (overwrite) with `>>` (append) -- Forgetting that `>` destroys existing file content -- Putting the filename before `>` instead of after -- Using `|` when `>` is needed (or vice versa) - -## 6. Time Budget -- Level 8.1: ~1 minute -- Level 8.2: ~1 minute -- Level 8.3: ~1 minute -- Level 8.4: ~2 minutes -- Level 8.5: ~1 minute -- Level 8.6: ~2 minutes -- Level 8.7: ~2 minutes (extension) - -**Core path (8.1–8.6): ~8 minutes** -**Full section: ~10 minutes** - -## 7. Dependencies -- Requires: Sections 1–5 (navigation, file inspection, cat) -- Leads to: Section 9 (stderr and advanced redirection) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index ebc7f8a..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,103 +0,0 @@ -[build-system] -requires = ["setuptools>=61.0", "wheel"] -build-backend = "setuptools.build_meta" - -[project] -name = "shellgame" -version = "0.1.0" -description = "Interactive terminal learning experience for filesystem navigation" -readme = "README.md" -requires-python = ">=3.10" -authors = [ - {name = "Jakub Dupak", email = "dev@jakubdupak.com"} -] -classifiers = [ - "Development Status :: 3 - Alpha", - "Intended Audience :: Education", - "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", - "Programming Language :: Python :: 3.14", -] - -license = {text = "GPL-3.0-only"} - -dependencies = [ - "click>=8.1.0", - "rich>=13.0.0", - "pydantic>=2.0.0", - "typing_extensions>=4.8.0", -] - -[project.optional-dependencies] -dev = [ - "pytest>=7.0.0", - "pytest-cov>=4.0.0", - "ruff>=0.1.0", - "mypy>=1.0.0", -] - -build = [ - "pyinstaller>=5.0.0", -] - -[project.scripts] -shellgame = "shellgame.__main__:cli" - -[tool.setuptools.package-data] -"shellgame.cli.templates" = ["*.template"] -"shellgame.levels.content" = ["*.md"] - -[tool.setuptools.packages.find] -where = ["src"] - -[tool.ruff] -line-length = 120 -target-version = "py310" - -[tool.ruff.lint] -select = ["E", "F", "I", "UP", "B", "SIM", "PL"] -ignore = ["PLR2004"] - -[tool.mypy] -python_version = "3.10" -warn_return_any = true -warn_unused_configs = true -disallow_untyped_defs = true - -[tool.pytest.ini_options] -testpaths = ["tests"] -python_files = ["test_*.py"] -python_classes = ["Test*"] -python_functions = ["test_*"] -addopts = "-v --cov=shellgame --cov-report=term-missing --cov-report=html" -pythonpath = ["src"] -markers = [ - "optional_shell_lint: optional shell linting tests (requires external tools like shellcheck/fish)", -] - -[tool.coverage.run] -source = ["src/shellgame"] -omit = [ - "tests/*", - "*/__init__.py", -] - -[tool.coverage.report] -exclude_lines = [ - "pragma: no cover", - "def __repr__", - "if self.debug:", - "if settings.DEBUG", - "raise AssertionError", - "raise NotImplementedError", - "if 0:", - "if __name__ == .__main__.:", - "class .*\\bProtocol\\):", - "@(abc\\.)?abstractmethod", -] -ignore_errors = true -fail_under = 60 diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..5a341ac --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.99.0" +components = ["rustfmt", "clippy"] +profile = "minimal" diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..d5ff75a --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,2 @@ +# Compactness over everything: calls, structs and chains stay on one line up to max_width. +use_small_heuristics = "Max" diff --git a/scripts/install.sh b/scripts/install.sh index 8c8187e..73ca1d2 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,35 +1,93 @@ -#!/usr/bin/env bash -set -euo pipefail +#!/bin/sh +# Installs the latest ShellGame release for this machine (Linux or macOS, x86_64 or aarch64). +# +# curl -fsSL https://raw.githubusercontent.com/jdupak/ShellGame/main/scripts/install.sh | bash +# +# Environment overrides: +# SHELLGAME_INSTALL_DIR directory to install `shellgame` into (default: the current directory) +# SHELLGAME_REPO GitHub repository to download from (default: jdupak/ShellGame) +# +# Runs in POSIX sh (dash, for `| sh`) and bash 3.2, the system bash on macOS. Everything happens in +# functions and `main` runs on the last line, so a download cut short runs nothing. +set -eu -REPO="${REPO:-jdupak/ShellGame}" -INSTALL_DIR="${INSTALL_DIR:-$PWD}" - -if [[ "$(uname -s)" != "Linux" ]]; then - echo "Unsupported OS: $(uname -s) (only Linux is currently published)" >&2 +fail() { + echo "$*" >&2 exit 1 -fi -os_name="linux" +} + +need() { + command -v "$1" >/dev/null 2>&1 || + fail "'$1' is required but was not found in PATH. Install it and run this script again." +} + +# shellgame-<os>-<arch>.tar.gz, the asset name CI publishes and `shellgame update` downloads. +asset_name() { + case "$(uname -s)" in + Linux) os_name=linux ;; + Darwin) os_name=macos ;; + *) fail "Unsupported OS: $(uname -s) (supported: Linux, macOS)" ;; + esac + case "$(uname -m)" in + x86_64 | amd64) arch_name=x86_64 ;; + aarch64 | arm64) arch_name=aarch64 ;; + *) fail "Unsupported architecture: $(uname -m) (supported: x86_64, aarch64/arm64)" ;; + esac + # A shell running under Rosetta 2 reports x86_64 even on Apple Silicon. Prefer the native build. + if [ "$os_name" = macos ] && [ "$arch_name" = x86_64 ] && + [ "$(sysctl -n hw.optional.arm64 2>/dev/null || true)" = 1 ]; then + arch_name=aarch64 + fi + echo "shellgame-$os_name-$arch_name.tar.gz" +} + +main() { + need curl + need tar + repo="${SHELLGAME_REPO:-jdupak/ShellGame}" + install_dir="${SHELLGAME_INSTALL_DIR:-$PWD}" + asset="$(asset_name)" + url="https://github.com/$repo/releases/latest/download/$asset" + + mkdir -p "$install_dir" + install_dir="$(CDPATH='' cd -- "$install_dir" && pwd)" -case "$(uname -m)" in - x86_64|amd64) - arch_name="x86_64" - ;; - *) - echo "Unsupported architecture: $(uname -m) (only x86_64 is currently published)" >&2 - exit 1 - ;; -esac + tmp_dir="$(mktemp -d)" + trap 'rm -rf "$tmp_dir"' EXIT + trap 'exit 1' HUP INT TERM -asset_name="shellgame-${os_name}-${arch_name}.tar.gz" -release_url="https://github.com/${REPO}/releases/latest/download/${asset_name}" + echo "Downloading $url" + if ! curl --proto '=https' --tlsv1.2 -fsSL --connect-timeout 20 --retry 3 \ + -o "$tmp_dir/$asset" "$url"; then + echo "Download failed: $url" >&2 + fail "Check your connection. A build of $asset may also not be published yet." + fi -mkdir -p "${INSTALL_DIR}" + tar -xzf "$tmp_dir/$asset" -C "$tmp_dir" shellgame || + fail "The downloaded archive $asset is damaged. Run this script again." + # Copied next to its place, tried there (/tmp may forbid running programs) and renamed over the + # old copy, which is never half written, not even while it runs. + new="$install_dir/.shellgame.new" + cp "$tmp_dir/shellgame" "$new" + chmod 755 "$new" + if ! "$new" --version >/dev/null 2>&1; then + rm -f "$new" + fail "The downloaded program does not run on this machine ($asset)." + fi + mv -f "$new" "$install_dir/shellgame" -tmp_dir="$(mktemp -d)" -trap 'rm -rf "${tmp_dir}"' EXIT + # Name the command that starts this copy: by name if it is the one on PATH, otherwise by path. + if [ "$(command -v shellgame || true)" = "$install_dir/shellgame" ]; then + run_cmd=shellgame + elif [ "$install_dir" = "$PWD" ]; then + run_cmd=./shellgame + else + run_cmd="$install_dir/shellgame" + fi -curl -fsSL "${release_url}" -o "${tmp_dir}/${asset_name}" -tar -xzf "${tmp_dir}/${asset_name}" -C "${tmp_dir}" -install -m 755 "${tmp_dir}/shellgame" "${INSTALL_DIR}/shellgame" + echo "Installed $("$install_dir/shellgame" --version) to $install_dir/shellgame" + echo "Start the game with: $run_cmd" + echo "Update it later with: $run_cmd update" +} -echo "Installed shellgame to ${INSTALL_DIR}/shellgame" +main "$@" diff --git a/shellgame.spec b/shellgame.spec deleted file mode 100644 index 651d4fe..0000000 --- a/shellgame.spec +++ /dev/null @@ -1,55 +0,0 @@ -# -*- mode: python ; coding: utf-8 -*- - -# Reproducible PyInstaller spec for ShellGame. -# Used by: `make build` - -from PyInstaller.utils.hooks import collect_data_files, collect_submodules - -block_cipher = None - -# Include runtime templates and level introductions bundled as package data. -# NOTE: We also include the package-data listings in pyproject.toml. -datas = [] -datas += collect_data_files("shellgame.cli.templates") -datas += collect_data_files("shellgame.levels.content") - - -a = Analysis( - ["src/shellgame/__main__.py"], - pathex=["."], - binaries=[], - datas=datas, - hiddenimports=collect_submodules("shellgame.levels.sections"), - hookspath=[], - hooksconfig={}, - runtime_hooks=[], - excludes=[], - win_no_prefer_redirects=False, - win_private_assemblies=False, - cipher=block_cipher, - noarchive=False, -) - -pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) - -exe = EXE( - pyz, - a.scripts, - a.binaries, - a.zipfiles, - a.datas, - [], - name="shellgame", - debug=False, - bootloader_ignore_signals=False, - strip=False, - upx=True, - upx_exclude=[], - runtime_tmpdir=None, - console=True, - disable_windowed_traceback=False, - argv_emulation=False, - target_arch=None, - codesign_identity=None, - entitlements_file=None, -) diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..579bec5 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,361 @@ +//! The command line: clap definitions with Czech help, the hooks of the game shell, booting the +//! game shell and dispatch. +//! +//! `cd-hook` and `guard` come from the game shell's functions and carry arbitrary text (a +//! `cd` target can be `-`, `--` or a directory named `pre`), so they are parsed by hand before clap. +//! Hooks fail open: anything malformed exits 0 and allows. Exit status: 0 for normal play (a wrong +//! answer included), 1 for errors and a declined `remove`, 2 for usage errors, 3 when a hook refuses. + +use std::ffi::OsString; +use std::io::{self, Write}; +use std::path::Path; + +use clap::{Arg, ArgAction, CommandFactory, FromArgMatches, Parser, Subcommand}; + +use crate::env::Env; +use crate::game::Pos; +use crate::sandbox; +use crate::session::{BLOCKED, FAILURE, Flow, Halt, OK, Session}; +use crate::shell::{self, Directives, ENV_PENDING, LaunchOptions, ShellKind}; +use crate::ui::{BOLD_RED, RED, Term, screens}; + +const BUILD: &str = match option_env!("SHELLGAME_BUILD") { + Some(build) => build, + None => "dev", +}; + +/// `shellgame 0.2.0 (build-123)` +pub fn version_line() -> String { + format!("shellgame {} ({BUILD})", env!("CARGO_PKG_VERSION")) +} + +const ROOT_TEMPLATE: &str = r#"{about} + +Použití: {usage} + +Možnosti: +{options} + +Příkazy: +{subcommands} +"#; +const COMMAND_TEMPLATE: &str = r#"{about} + +Použití: {usage} + +{all-args} +"#; + +#[derive(Debug, Parser)] +#[command( + name = "shellgame", + about = "ShellGame - Interaktivní výuka navigace v terminálu.", + disable_help_flag = true, + disable_version_flag = true, + disable_help_subcommand = true, + help_template = ROOT_TEMPLATE, + override_usage = "shellgame [MOŽNOSTI] [PŘÍKAZ]", + subcommand_help_heading = "Příkazy", + next_help_heading = "Možnosti" +)] +struct Cli { + #[arg(long, value_name = "bash|fish", value_parser = ["bash", "fish"], ignore_case = true, hide_possible_values = true, help = "Vynutit typ subshellu (bash/fish).")] + shell: Option<String>, + + #[arg( + long = "unsafe", + help = "Spustit hru bez izolace, na vlastní riziko (když systém nemá Landlock)." + )] + unsafe_mode: bool, + + #[arg(short = 'V', long, help = "Zobrazit verzi a ukončit.")] + version: bool, + + #[command(subcommand)] + command: Option<Command>, +} + +#[derive(Debug, Subcommand)] +enum Command { + #[command( + about = "Zobrazit dosud odemčené nápovědy aktuálního levelu a odemknout další.", + override_usage = "shellgame hint [MOŽNOSTI]" + )] + Hint { + #[arg(short, long, help = "Jen zopakovat odemčené nápovědy, další neodemykat.")] + repeat: bool, + }, + #[command( + about = "Odeslat odpověď pro aktuální level.", + override_usage = "shellgame submit [ODPOVĚĎ]..." + )] + Submit { + #[arg(allow_hyphen_values = true, num_args = 0.., value_name = "ODPOVĚĎ", help_heading = "Argumenty", help = "Odpověď (více slov se spojí mezerou).")] + answer: Vec<String>, + }, + #[command(about = "Zobrazit postup a statistiky.", override_usage = "shellgame status")] + Status, + #[command(about = "Obnovit strukturu aktuálního levelu.", override_usage = "shellgame reset")] + Reset, + #[command( + about = "Znovu zobrazit zadání levelu nebo úvod sekce.", + override_usage = "shellgame show [S.L | S]", + alias = "repeat" + )] + Show { + #[arg( + value_name = "S.L | S", + help_heading = "Argumenty", + help = "Pozice levelu (např. 3.2), nebo číslo sekce (např. 3) pro její úvod. Bez argumentu se zobrazí aktuální level." + )] + target: Option<String>, + }, + #[command(about = "Vypsat všechny levely.", override_usage = "shellgame levels")] + Levels, + #[command( + about = "Pokračovat od zvoleného levelu, např. na jiném počítači.", + override_usage = "shellgame resume <S.L | S>" + )] + Resume { + #[arg( + value_name = "S.L | S", + help_heading = "Argumenty", + help = "Pozice levelu (např. 3.2), nebo číslo sekce (např. 3) pro její úvod a první level." + )] + target: Option<String>, + }, + #[command( + about = "Smazat stav a pracovní prostor.", + override_usage = "shellgame remove [MOŽNOSTI]" + )] + Remove { + #[arg(long, help = "Potvrdit bez dotazu.")] + yes: bool, + }, + #[command(about = "Ukončit ShellGame.", override_usage = "shellgame exit")] + Exit, + #[command( + about = "Aktualizovat ShellGame na nejnovější vydání.", + override_usage = "shellgame update" + )] + Update, +} + +/// `--help` in Czech, for every command. +fn help_arg() -> Arg { + Arg::new("help") + .short('h') + .long("help") + .action(ArgAction::Help) + .help("Zobrazit tuto nápovědu a ukončit.") + .help_heading("Možnosti") +} + +/// Czech headings and help flag for a subcommand. +fn localize(command: clap::Command) -> clap::Command { + command + .disable_help_flag(true) + .help_template(COMMAND_TEMPLATE) + .mut_args(|arg| match arg.get_help_heading() { + Some(_) => arg, + None => arg.help_heading("Možnosti"), + }) + .arg(help_arg()) +} + +fn parse(args: &[OsString]) -> Result<Cli, clap::Error> { + let command = Cli::command().arg(help_arg()).mut_subcommands(localize); + let matches = command.try_get_matches_from( + std::iter::once(OsString::from("shellgame")).chain(args.iter().cloned()), + )?; + Cli::from_arg_matches(&matches) +} + +/// Prints a command-line error and returns its status. Help and the version go out as clap writes +/// them; a mistyped command, argument or value is told in Czech (clap's words are English). +fn usage(error: clap::Error) -> i32 { + use clap::error::{ContextKind as K, ContextValue, ErrorKind}; + let value = |kind| match error.get(kind)? { + ContextValue::String(text) => Some(text.clone()), + ContextValue::Strings(texts) => Some(texts.join(", ")), + _ => None, + }; + let similar = |kind| value(kind).map(|name| format!(" Měli jste na mysli '{name}'?")); + let what = match error.kind() { + ErrorKind::InvalidSubcommand => value(K::InvalidSubcommand).map(|name| { + format!( + "Neznámý příkaz '{name}'.{}", + similar(K::SuggestedSubcommand).unwrap_or_default() + ) + }), + ErrorKind::UnknownArgument => value(K::InvalidArg).map(|arg| { + format!("Nečekaný argument '{arg}'.{}", similar(K::SuggestedArg).unwrap_or_default()) + }), + ErrorKind::InvalidValue => value(K::InvalidValue) + .zip(value(K::ValidValue)) + .map(|(given, valid)| format!("Neplatná hodnota '{given}'. Možnosti: {valid}.")), + _ => None, + }; + let Some(what) = what else { + let _ = error.print(); + return error.exit_code(); + }; + let term = Term::stderr(); + term.print(&screens::say(&term, RED, &format!("Chyba: {what}"))); + term.print(&screens::cmd_note(&term, "Přehled příkazů: ", "shellgame --help")); + error.exit_code() +} + +/// The hook commands of the game shell; `None` for everything else. +fn hook(args: &[OsString], env: &Env) -> Option<i32> { + let (name, rest) = args.split_first()?; + Some(match (name.to_str()?, rest) { + ("guard", [separator, chmod_args @ ..]) if separator == "--" => { + match sandbox::guard(env.cwd.as_deref().unwrap_or(Path::new(".")), chmod_args) { + Ok(()) => OK, + Err(refusal) => { + let _ = writeln!(io::stderr(), "{refusal}"); + BLOCKED + } + } + } + ("cd-hook", [phase, first, second, separator, cd_args @ ..]) + if separator == "--" && (phase == "pre" || phase == "post") => + { + Session::new(env).cd_hook(phase == "post", Path::new(first), Path::new(second), cd_args) + } + ("guard" | "cd-hook", _) => OK, + _ => return None, + }) +} + +/// Typed outside the game shell, these start it and run inside it: the bare command, and the ones +/// that need a game (`resume` only with a level to go to; without one it just lists the levels). +fn starts_game_shell(command: &Option<Command>) -> bool { + match command { + None | Some(Command::Reset | Command::Submit { .. }) => true, + Some(Command::Resume { target }) => { + target.as_deref().is_some_and(|t| t.parse::<Pos>().is_ok()) + } + _ => false, + } +} + +/// What the new game shell runs once it is up: the subcommand and its arguments, joined by U+001F. +fn pending(command: &Option<Command>) -> Option<String> { + let words = match command { + Some(Command::Reset) => vec!["reset"], + Some(Command::Submit { answer }) => { + ["submit"].into_iter().chain(answer.iter().map(String::as_str)).collect() + } + Some(Command::Resume { target: Some(target) }) => vec!["resume", target], + _ => return None, + }; + Some(words.join("\u{1f}")) +} + +/// `shellgame` outside the game shell starts it, with the command that was typed (if any) left for +/// the new shell in `SHELLGAME_PENDING`. Its own exit status is not a failure. The game +/// shell is confined where Landlock works, unconfined only when the player asked for it with +/// `--unsafe` (then the probe is not even run), otherwise it does not start at all. +fn boot(cli: &Cli, env: &Env) -> i32 { + let term = Term::stdout(); + let Some(binary) = env.exe.clone() else { + term.print(&screens::say(&term, BOLD_RED, "CHYBA: Nelze zjistit cestu k programu.")); + return FAILURE; + }; + let confined = !cli.unsafe_mode; + if confined && !sandbox::probe(&binary, &env.state_dir) { + term.print(&screens::refusal(&term)); + return FAILURE; + } + let shell = shell::detect(cli.shell.as_deref(), env.shell.as_deref()); + // fish makes its own directories on its first start, which the sandbox forbids, and fish 3.3 + // then warns on every start: make them as fish would, before the shell is confined. + if confined && shell == ShellKind::Fish { + for home in [&env.config_home, &env.data_home] { + let _ = std::fs::create_dir_all(home.join("fish")); + } + } + let options = + LaunchOptions { shell, binary, state_dir: env.state_dir.clone(), sandbox: confined }; + let pending = pending(&cli.command); + let configure = |command: &mut std::process::Command| match pending { + Some(pending) => drop(command.env(ENV_PENDING, pending)), + None => drop(command.env_remove(ENV_PENDING)), + }; + match shell::launch_with(&options, configure) { + Ok(_) => OK, + Err(error) => { + term.print(&screens::say( + &term, + BOLD_RED, + &format!("CHYBA: Nepodařilo se spustit herní shell ({error})"), + )); + FAILURE + } + } +} + +fn dispatch(session: &mut Session, command: Option<Command>) -> Flow { + match command { + None => session.current(), + Some(Command::Hint { repeat }) => session.hint(repeat), + Some(Command::Submit { answer }) => { + session.submit((!answer.is_empty()).then(|| answer.join(" "))) + } + Some(Command::Status) => session.status(), + Some(Command::Reset) => session.reset(), + Some(Command::Show { target }) => session.show(target.as_deref()), + Some(Command::Levels) => session.levels(), + Some(Command::Resume { target }) => session.resume(target.as_deref()), + Some(Command::Remove { yes }) => session.remove(yes), + Some(Command::Exit) => session.exit(), + Some(Command::Update) => session.update(), + } +} + +/// Runs the command line of this process; returns the exit status. +pub fn main() -> i32 { + let (args, env) = (std::env::args_os().skip(1).collect::<Vec<_>>(), Env::from_process()); + if let Some(status) = hook(&args, &env) { + return status; + } + let cli = match parse(&args) { + Ok(cli) => cli, + Err(error) => return usage(error), + }; + if cli.version { + let _ = writeln!(io::stdout(), "{}", version_line()); + return OK; + } + let mut session = Session::new(&env); + if let Err(why) = env.check() { + return session.finish(Err(Halt::Fail(why))); + } + if !env.wrapped && starts_game_shell(&cli.command) { + return boot(&cli, &env); + } + let mut command = cli.command; + // The autostart of a game shell that was started for a command: run it once, as if typed here. + if let (true, None, Some(pending)) = (env.wrapped, &command, &env.pending) { + Directives::new(true).export(ENV_PENDING, ""); + let args: Vec<OsString> = pending.split('\u{1f}').map(OsString::from).collect(); + match parse(&args) { + Ok(parsed) => command = parsed.command, + Err(error) => return usage(error), + } + } + let result = dispatch(&mut session, command); + session.finish(result) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_clap_definition_is_consistent() { + Cli::command().debug_assert(); + } +} diff --git a/src/env.rs b/src/env.rs new file mode 100644 index 0000000..a1d7ef1 --- /dev/null +++ b/src/env.rs @@ -0,0 +1,85 @@ +//! The process environment, read once in `main` and passed down, so nothing below reads +//! `std::env` or the working directory. An empty variable counts as unset. + +use std::ffi::OsString; +use std::path::{self, PathBuf}; + +use crate::game::paths::canonical; +use crate::shell::{ENV_PENDING, ENV_SANDBOX, ENV_WORKSPACE}; + +#[derive(Clone, Debug)] +pub struct Env { + /// The logical `$HOME`. + pub home: PathBuf, + pub user: String, + /// The player's directory, canonical (`getcwd` resolves every symlink, and unlike + /// `canonicalize` it works when a parent lacks `x`); `None` when it no longer exists. + pub cwd: Option<PathBuf>, + /// `$XDG_CONFIG_HOME` or `~/.config`, and `$XDG_DATA_HOME` or `~/.local/share`. + pub config_home: PathBuf, + pub data_home: PathBuf, + /// Where `state.json` lives: `$SHELLGAME_STATE_DIR` or `<config_home>/shellgame`. + pub state_dir: PathBuf, + /// The workspace of a new game: `$SHELLGAME_WORKSPACE` or `/tmp/shellgame-<user>`. + pub workspace: PathBuf, + /// Running inside the game shell, which replays the directives on stderr: the launcher sets + /// `SHELLGAME_SANDBOX`, nothing else does. + pub wrapped: bool, + /// `landlock` or `off`, set by the launcher; absent outside the game shell. + pub sandbox: Option<String>, + /// A command typed outside the game shell, left by the launcher for the autostart: the subcommand + /// and its arguments joined by U+001F. + pub pending: Option<String>, + /// `$SHELL`, for shell detection. + pub shell: Option<OsString>, + pub update_url: Option<String>, + /// This binary, canonical. + pub exe: Option<PathBuf>, +} + +impl Env { + pub fn from_process() -> Env { + let var = |name: &str| std::env::var_os(name).filter(|value| !value.is_empty()); + let text = |name: &str| var(name).map(|value| value.to_string_lossy().into_owned()); + let home = var("HOME") + .map(PathBuf::from) + .or_else(std::env::home_dir) + .unwrap_or_else(|| "/".into()); + let user = text("USER").unwrap_or_else(|| "player".into()); + let absolute = |dir: PathBuf| path::absolute(&dir).unwrap_or(dir); + let config_home = + absolute(var("XDG_CONFIG_HOME").map_or_else(|| home.join(".config"), PathBuf::from)); + let data_home = + absolute(var("XDG_DATA_HOME").map_or_else(|| home.join(".local/share"), PathBuf::from)); + let state_dir = + var("SHELLGAME_STATE_DIR").map_or_else(|| config_home.join("shellgame"), PathBuf::from); + let workspace = var(ENV_WORKSPACE) + .map(PathBuf::from) + .unwrap_or_else(|| format!("/tmp/shellgame-{user}").into()); + let sandbox = text(ENV_SANDBOX); + Env { + cwd: std::env::current_dir().ok(), + state_dir: absolute(state_dir), + config_home, + data_home, + workspace: absolute(workspace), + wrapped: sandbox.is_some(), + sandbox, + pending: text(ENV_PENDING), + shell: var("SHELL"), + update_url: text("SHELLGAME_UPDATE_URL"), + exe: std::env::current_exe().ok().and_then(|exe| exe.canonicalize().ok()), + home, + user, + } + } + + /// The state directory stays writable inside the sandbox, so it must not be `$HOME` or above it. + pub fn check(&self) -> Result<(), String> { + if canonical(&self.home).starts_with(canonical(&self.state_dir)) { + let what = "Adresář pro uložení hry nesmí být domovský adresář ani adresář nad ním"; + return Err(format!("{what}: {}", self.state_dir.display())); + } + Ok(()) + } +} diff --git a/src/game/answer.rs b/src/game/answer.rs new file mode 100644 index 0000000..da19c41 --- /dev/null +++ b/src/game/answer.rs @@ -0,0 +1,213 @@ +//! How a submitted answer is judged. Order: trim, and drop trailing slashes (Tab completion adds +//! one to a directory name) -> literal mistakes -> correct? -> `error` or "Nesprávně." The default +//! wrong text never contains the answer; a hint about it belongs in the level's hints. + +use super::messages::{self, or}; + +#[derive(Clone, Copy)] +enum Kind { + None, + Exact(&'static str), + OneOf(&'static [&'static str]), + Int(i64), + Suffix(&'static str), + Parts(&'static str, &'static [Answer]), +} + +/// The texts are empty when the default applies. +#[derive(Clone, Copy)] +pub struct Answer { + kind: Kind, + nocase: bool, + wrong: &'static [(&'static str, &'static str)], + error: &'static str, + format: &'static str, +} + +/// The level takes no answer. +pub const NO_ANSWER: Answer = + Answer { kind: Kind::None, nocase: false, wrong: &[], error: "", format: "" }; + +#[rustfmt::skip] +mod vocabulary { + use super::*; + + const fn new(kind: Kind) -> Answer { Answer { kind, ..NO_ANSWER } } + + pub const fn exact(expected: &'static str) -> Answer { new(Kind::Exact(expected)) } + pub const fn one_of(accepted: &'static [&'static str]) -> Answer { new(Kind::OneOf(accepted)) } + /// A text that is not a number is a wrong answer like any other. + pub const fn int(expected: i64) -> Answer { new(Kind::Int(expected)) } + /// The expected text, or a path ending in `/expected`. + pub const fn suffix(expected: &'static str) -> Answer { new(Kind::Suffix(expected)) } + /// Several values joined by `separator`, each judged by its own rule; the first failure wins. + pub const fn parts(separator: &'static str, parts: &'static [Answer]) -> Answer { new(Kind::Parts(separator, parts)) } + + impl Answer { + pub const fn nocase(self) -> Self { Self { nocase: true, ..self } } + /// Tailored messages for specific wrong answers (compared like the answer, integers by + /// value), checked before the correct answer in the order given. + pub const fn mistakes(self, wrong: &'static [(&'static str, &'static str)]) -> Self { Self { wrong, ..self } } + /// The wrong-answer text instead of "Nesprávně." + pub const fn error(self, error: &'static str) -> Self { Self { error, ..self } } + /// How to submit: the text when no answer is given, and for a `parts` answer with the + /// wrong number of parts. + pub const fn format(self, format: &'static str) -> Self { Self { format, ..self } } + } +} +pub use vocabulary::*; + +impl Answer { + /// `None` is a submission without an answer. + pub fn judge(&self, answer: Option<&str>) -> Result<(), String> { + match (self.kind, answer) { + (Kind::None, _) => Ok(()), + (_, Some(text)) => self.verdict(text), + (_, None) => Err(or(self.format, messages::ANSWER_REQUIRED)), + } + } + + fn verdict(&self, text: &str) -> Result<(), String> { + let text = text.trim().trim_end_matches('/'); + let fold = |s: &str| { + if self.nocase { s.to_lowercase() } else { s.to_owned() } + }; + let got = fold(text); + let number = text.parse::<i64>().ok(); + let known = self.wrong.iter().find(|(key, _)| match self.kind { + Kind::Int(_) => number.is_some_and(|n| key.parse() == Ok(n)), + _ => fold(key) == got, + }); + let correct = match self.kind { + _ if known.is_some() => false, + Kind::Parts(separator, parts) => { + let values: Vec<&str> = text.split(separator).collect(); + if values.len() != parts.len() { + return Err(or(self.format, messages::ANSWER_FORMAT)); + } + return parts.iter().zip(values).try_for_each(|(rule, value)| rule.verdict(value)); + } + Kind::Int(expected) => number == Some(expected), + Kind::Exact(expected) => fold(expected) == got, + Kind::OneOf(accepted) => accepted.iter().any(|value| fold(value) == got), + Kind::Suffix(expected) => { + let expected = fold(expected); + got == expected || got.ends_with(&format!("/{expected}")) + } + Kind::None => true, + }; + if correct { + return Ok(()); + } + let message = known.map(|(_, message)| (*message).to_owned()); + Err(message.unwrap_or_else(|| or(self.error, messages::INCORRECT))) + } + + #[cfg(test)] + pub fn is_set(&self) -> bool { + !matches!(self.kind, Kind::None) + } + + /// One correct answer as a player would type it. + #[cfg(test)] + pub fn sample(&self) -> Option<String> { + match self.kind { + Kind::Exact(expected) | Kind::Suffix(expected) => Some(expected.to_owned()), + Kind::OneOf(accepted) => accepted.first().map(|value| (*value).to_owned()), + Kind::Int(expected) => Some(expected.to_string()), + Kind::Parts(separator, parts) => { + let values: Option<Vec<String>> = parts.iter().map(Answer::sample).collect(); + values.map(|values| values.join(separator)) + } + Kind::None => None, + } + } + + /// Every static correct answer and every text a wrong answer can show, this rule's and its parts'. + #[cfg(test)] + pub fn secrets_and_texts(&self) -> (Vec<String>, Vec<&'static str>) { + let (mut secrets, mut texts) = (self.sample().into_iter().collect::<Vec<_>>(), vec![]); + match self.kind { + Kind::OneOf(accepted) => { + secrets.extend(accepted.iter().map(|value| (*value).to_owned())) + } + Kind::Parts(_, parts) => { + for (inner_secrets, inner_texts) in parts.iter().map(Answer::secrets_and_texts) { + secrets.extend(inner_secrets); + texts.extend(inner_texts); + } + } + _ => {} + } + texts.extend([self.error, self.format]); + texts.extend(self.wrong.iter().map(|(_, message)| *message)); + texts.retain(|text| !text.is_empty()); + (secrets, texts) + } +} + +#[cfg(test)] +mod tests { + use super::messages::*; + use super::*; + use crate::game::testing::err; + + fn judge(answer: Answer, text: &str) -> Result<(), String> { + answer.judge(Some(text)) + } + + #[test] + fn text_answers_are_trimmed_folded_and_the_default_text_is_terse() { + const A: Answer = exact("Delta").nocase(); + assert!(["Delta", " delta/\n", "DELTA//"].iter().all(|ok| judge(A, ok).is_ok())); + assert_eq!(judge(exact("Delta"), "delta"), err("Nesprávně.")); + const SUFFIX: Answer = suffix("config/settings.py"); + assert!( + ["x/config/settings.py", "./config/settings.py"] + .iter() + .all(|ok| judge(SUFFIX, ok).is_ok()) + ); + assert!( + ["settings.py", "config/settings.pyc"].iter().all(|bad| judge(SUFFIX, bad).is_err()) + ); + const ONE: Answer = one_of(&["a", "b"]); + assert!(judge(ONE, "b").is_ok() && judge(ONE, "c").is_err()); + } + + #[test] + fn the_order_is_mistakes_then_correct_then_error() { + const A: Answer = exact("delta") + .mistakes(&[("delta", "a mistake wins over the answer"), ("dome", "the dome")]) + .error("plain"); + assert_eq!(judge(A, "delta"), err("a mistake wins over the answer")); + assert_eq!([judge(A, "dome"), judge(A, "x")], [err("the dome"), err("plain")]); + const B: Answer = exact("Delta").nocase().mistakes(&[("DOME", "dome")]); + assert_eq!(judge(B, "dome/"), err("dome")); + } + + #[test] + fn integers_and_parts_report_what_is_wrong_with_them() { + const N: Answer = int(5).mistakes(&[("3", "three")]).error("e"); + assert!(["5", " 5 ", "+5"].iter().all(|ok| judge(N, ok).is_ok())); + assert_eq!([judge(N, "+3"), judge(N, "6")], [err("three"), err("e")]); + assert!(["x", "5.0", "", "five"].iter().all(|bad| judge(N, bad) == err("e"))); + const P: Answer = parts(",", &[int(2).error("count"), exact("Key").nocase().error("key")]) + .format("shape"); + assert!(judge(P, "2,key").is_ok() && judge(P, " 2 , KEY ").is_ok()); + assert_eq!([judge(P, "2"), judge(P, "2,key,x")], [err("shape"), err("shape")]); + let wrong = [judge(P, "3,nope"), judge(P, "2,nope"), judge(P, "x,key")]; + assert_eq!(wrong, [err("count"), err("key"), err("count")]); + const DEFAULT: Answer = parts(",", &[exact("a"), exact("b")]); + assert_eq!(judge(DEFAULT, "a"), err(ANSWER_FORMAT)); + } + + #[test] + fn a_missing_answer_shows_the_format_or_asks_for_one() { + const PARTS: Answer = parts(",", &[exact("a")]); + for answer in [exact("a"), int(1), PARTS] { + assert_eq!(answer.judge(None), err(ANSWER_REQUIRED)); + } + assert_eq!(exact("a").format("say it").judge(None), err("say it")); + assert!(NO_ANSWER.judge(None).is_ok() && judge(NO_ANSWER, "anything").is_ok()); + } +} diff --git a/src/game/cd.rs b/src/game/cd.rs new file mode 100644 index 0000000..21529c8 --- /dev/null +++ b/src/game/cd.rs @@ -0,0 +1,298 @@ +//! Strict `cd` levels: a level teaches one movement command and the game blocks everything else. +//! +//! A level's `cd` is a flat list of clauses. *Scope* ([`when_in`]) says which moves the clauses are +//! about; *rules* ([`exact`], [`absolute`], [`within`], [`custom`]) say what an in-scope move must +//! satisfy, checked in order, the first failure blocking with its message; [`arrive`] narrows +//! evidence to one destination. +//! +//! The shell asks twice per `cd`. [`before`]: out of scope -> allow; *lifted* (the level's +//! `.cd_done` exists, or the destination is the level's start directory, so a wrong step can +//! always be undone) -> allow; otherwise the first failing rule blocks. [`after`], once the move +//! succeeded: unless a [`custom`] clause exists, record `.cd_done` when the move was in scope, +//! every rule passes and the player landed on the `arrive` directory (if any); lifting never +//! grants or denies evidence. Custom `track` functions run after every successful move and decide +//! evidence themselves. Directories are compared canonically, except what custom code compares. + +use std::path::{Path, PathBuf}; + +use super::paths::{canonical, join, normalize, same, split}; +use super::{CD_DONE, Ctx, Pos, start_dir}; + +/// One attempted (or completed) `cd`. +#[derive(Clone, Copy)] +pub struct CdMove<'a> { + /// The first non-option argument: `""` for a bare `cd`, `"-"` for `cd -`. + pub target: &'a str, + /// The logical directory the player is in before the move, as the shell reports it. + pub pwd: &'a Path, + /// The shell's previous directory, which `cd -` goes to. + pub prev: Option<&'a Path>, + /// After the move: the logical `$PWD` the shell ended up in. + pub dest: Option<&'a Path>, + pub ctx: &'a Ctx<'a>, +} + +impl CdMove<'_> { + pub fn is_bare(&self) -> bool { + self.target.is_empty() + } + + /// Where the move goes, as the shell's logical `$PWD` will read: `dest` after the move, before + /// it the target resolved like `cd -L` (`""` is `$HOME`, `-` the previous directory, a + /// relative one is relative to `pwd`, `.` and `..` resolved textually). `None` when unknown. + pub fn destination(&self) -> Option<PathBuf> { + if let Some(dest) = self.dest { + return Some(dest.to_path_buf()); + } + let raw = match self.target { + "" => self.ctx.home.to_path_buf(), + "-" => self.prev?.to_path_buf(), + target => self.pwd.join(target), + }; + Some(normalize(&raw)).filter(|path| path.is_absolute()) + } +} + +type Rule = fn(&CdMove) -> Result<(), String>; +type Track = fn(&CdMove); + +#[derive(Clone, Copy)] +enum Kind { + WhenIn(&'static str), + Exact(&'static str, &'static str), + Absolute(&'static str), + Within(&'static str, &'static str), + Arrive(&'static str), + Custom(Rule, Track), +} + +#[derive(Clone, Copy)] +pub struct Cd(Kind); + +#[rustfmt::skip] +mod vocabulary { + use super::*; + + /// Scope: only moves that start in this directory. + pub const fn when_in(path: &'static str) -> Cd { Cd(Kind::WhenIn(path)) } + /// Rule: the target is this command, ignoring whitespace and trailing slashes (`../` is `..`). + pub const fn exact(command: &'static str, msg: &'static str) -> Cd { Cd(Kind::Exact(command, msg)) } + /// Rule: the target is an absolute path (a bare `cd` fails it). + pub const fn absolute(msg: &'static str) -> Cd { Cd(Kind::Absolute(msg)) } + /// Rule: the destination stays inside this directory (a bare `cd`, which goes home, does not). + pub const fn within(path: &'static str, msg: &'static str) -> Cd { Cd(Kind::Within(path, msg)) } + /// Evidence only when the move lands in this directory. + pub const fn arrive(path: &'static str) -> Cd { Cd(Kind::Arrive(path)) } + /// A rule with its own messages (run before the move and again for evidence) and a tracker that + /// runs after every successful move and records evidence itself (see `Ctx::mark`). + pub const fn custom(rule: Rule, track: Track) -> Cd { Cd(Kind::Custom(rule, track)) } +} +pub use vocabulary::*; + +#[cfg(test)] +impl Cd { + pub fn paths(&self) -> Vec<&'static str> { + match self.0 { + Kind::WhenIn(p) | Kind::Within(p, _) | Kind::Arrive(p) => vec![p], + _ => vec![], + } + } +} + +/// `Err` carries the message of a blocked move (the UI adds `CD_RECOVERY_TIP`). +pub fn before(pos: Pos, mv: &CdMove) -> Result<(), String> { + decide(pos.level().cd, &start_dir(pos, mv.ctx.workspace), mv) +} + +pub fn after(pos: Pos, mv: &CdMove) { + record(pos.level().cd, mv); +} + +fn decide(clauses: &[Cd], start: &Path, mv: &CdMove) -> Result<(), String> { + let lifted = + mv.ctx.marker(CD_DONE).exists() || mv.destination().is_some_and(|dest| same(&dest, start)); + if !in_scope(clauses, mv) || lifted { + return Ok(()); + } + first_failure(clauses, mv).map_or(Ok(()), Err) +} + +fn record(clauses: &[Cd], mv: &CdMove) { + if clauses.is_empty() { + return; + } + let mut tracked = false; + for clause in clauses { + if let Kind::Custom(_, track) = clause.0 { + tracked = true; + track(mv); + } + } + let arrived = clauses.iter().all(|clause| match clause.0 { + Kind::Arrive(path) => { + dir(mv.ctx, path).zip(mv.destination()).is_some_and(|(dir, dest)| same(&dest, &dir)) + } + _ => true, + }); + if !tracked && arrived && in_scope(clauses, mv) && first_failure(clauses, mv).is_none() { + // Best effort: a marker that cannot be written must not break the player's `cd`. + let _ = mv.ctx.mark(CD_DONE, ""); + } +} + +fn dir(ctx: &Ctx, rel: &str) -> Option<PathBuf> { + split(rel).ok().map(|_| join(&ctx.section_root, rel)) +} + +/// True when `pwd` (reported by the shell, so absolute) is this directory. +fn at_dir(ctx: &Ctx, pwd: &Path, rel: &str) -> bool { + pwd.is_absolute() && dir(ctx, rel).is_some_and(|dir| same(pwd, &dir)) +} + +fn in_scope(clauses: &[Cd], mv: &CdMove) -> bool { + clauses.iter().all(|clause| match clause.0 { + Kind::WhenIn(path) => at_dir(mv.ctx, mv.pwd, path), + _ => true, + }) +} + +fn first_failure(clauses: &[Cd], mv: &CdMove) -> Option<String> { + clauses.iter().find_map(|clause| { + let msg = match clause.0 { + Kind::Exact(command, msg) => (compare(mv.target) != compare(command)).then_some(msg), + Kind::Absolute(msg) => (!mv.target.starts_with('/')).then_some(msg), + Kind::Within(path, msg) => { + // A destination nobody can know (`cd -` without history) is the shell's to refuse. + let inside = mv + .destination() + .zip(dir(mv.ctx, path)) + .is_none_or(|(dest, dir)| canonical(&dest).starts_with(canonical(&dir))); + (!inside).then_some(msg) + } + Kind::Custom(rule, _) => return rule(mv).err(), + _ => None, + }?; + Some(msg.to_owned()) + }) +} + +/// A target without spacing or trailing slashes (but `/` stays `/`). +fn compare(target: &str) -> &str { + let target = target.trim(); + match target.trim_end_matches('/') { + "" if target.len() > 1 => "/", + trimmed if target.len() > 1 => trimmed, + _ => target, + } +} + +#[cfg(test)] +mod tests { + use std::fs; + + use super::*; + use crate::game::testing::{World, err}; + + const ALLOW: Result<(), String> = Ok(()); + + /// A level's clauses played in section 1 of a workspace; `start` is its start directory. + struct Drill { + w: World, + clauses: Vec<Cd>, + start: String, + } + + impl Drill { + fn new(clauses: &[Cd], start: &str) -> Self { + Drill { w: World::new(), clauses: clauses.to_vec(), start: start.to_owned() } + } + + /// A move from `pwd` (`cd -` goes to `prev`): the decision and, if the move happened, + /// whether it was recorded as the drill. + fn go(&self, pwd: &str, target: &str, prev: Option<&str>) -> (Result<(), String>, bool) { + let ctx = self.w.ctx(None); + let (pwd, prev) = (self.w.root().join(pwd), prev.map(|prev| self.w.root().join(prev))); + let mv = CdMove { target, pwd: &pwd, prev: prev.as_deref(), dest: None, ctx: &ctx }; + let decision = decide(&self.clauses, &self.w.root().join(&self.start), &mv); + if decision.is_ok() + && let Some(dest) = mv.destination() + { + record(&self.clauses, &CdMove { dest: Some(&dest), ..mv }); + } + let recorded = ctx.marker(CD_DONE).exists(); + let _ = fs::remove_file(ctx.marker(CD_DONE)); + (decision, recorded) + } + + fn decide(&self, pwd: &str, target: &str) -> Result<(), String> { + self.go(pwd, target, None).0 + } + + fn touch(&self, marker: &str) { + fs::write(self.w.ws.join(marker), "").unwrap(); + } + } + + #[test] + fn rules_are_checked_in_order_inside_their_scope() { + let d = Drill::new( + &[when_in("a/b/c"), exact("../..", "exactly"), absolute("absolute")], + "a/b/c", + ); + assert_eq!( + [d.decide("a/b/c", "/etc"), d.decide("a/b/c", "../../")], + [err("exactly"), err("absolute")] + ); + assert_eq!(d.decide("a", "/etc"), ALLOW, "out of scope"); + let d = Drill::new(&[exact("../..", "exactly")], "a"); + assert_eq!( + ["../..", "../../", " ../.. "].map(|target| d.decide("a/b/c", target)), + [ALLOW; 3] + ); + assert_eq!(d.decide("a/b/c", "/"), err("exactly")); + // `cd -` is a target like any other, and a scope does not need a rule to be useful. + let d = Drill::new(&[when_in("b"), exact("-", "back")], "a"); + assert_eq!([d.decide("a", ".."), d.decide("b", "-")], [ALLOW, ALLOW]); + assert_eq!(d.decide("b", ".."), err("back")); + } + + #[test] + fn a_bare_cd_goes_home_and_gets_the_message_of_the_rule_it_fails() { + let d = Drill::new(&[within("a", "stay")], "a"); + assert_eq!([d.decide("a/b", ""), d.decide("a/b", "../..")], [err("stay"), err("stay")]); + assert_eq!(d.decide("a/b", "-"), ALLOW, "an unknown destination is the shell's to refuse"); + let d = Drill::new(&[absolute("absolute")], "a"); + assert_eq!(d.decide("a/b", ""), err("absolute")); + } + + #[test] + fn enforcement_is_lifted_by_the_drill_and_by_the_way_back_to_the_start() { + let d = Drill::new(&[exact("..", "only ..")], "a"); + assert_eq!(d.decide("a/b", "../.."), err("only ..")); + // The start directory is always reachable: by path, by `cd -` and by a longer way round. + let start = d.w.root().join("a").display().to_string(); + let back = [ + d.decide("a/b/c", &start), + d.go("a/b/c", "-", Some("a")).0, + d.decide("x", "../a/b/.."), + ]; + assert_eq!(back, [ALLOW; 3]); + d.touch(CD_DONE); + assert_eq!(d.decide("a/b", "../.."), ALLOW); + } + + #[test] + fn evidence_needs_every_rule_and_the_arrival_but_lifting_does_not_matter() { + let d = Drill::new(&[when_in("x"), exact("-", "only -"), arrive("a")], "a"); + // `cd -` back to the start (a lifted move) still counts when the rules pass. + assert_eq!(d.go("x", "-", Some("a")), (ALLOW, true)); + assert_eq!(d.go("x", "-", Some("x")), (ALLOW, false), "wrong arrival"); + assert_eq!(d.go("a", "-", Some("a")), (ALLOW, false), "out of scope"); + assert_eq!(d.go("x", "..", None), (err("only -"), false), "a failed rule"); + let d = Drill::new(&[exact("..", "only ..")], "a"); + assert_eq!( + [d.go("a/b", "..", None), d.go("a/b", "../..", None)], + [(ALLOW, true), (err("only .."), false)] + ); + } +} diff --git a/src/game/check.rs b/src/game/check.rs new file mode 100644 index 0000000..5318750 --- /dev/null +++ b/src/game/check.rs @@ -0,0 +1,364 @@ +//! Conditions on the player's work, always checked (in order) before the answer, so a correct +//! answer next to unfinished work still fails. Declared paths are relative to the section root; a +//! path through a symlink fails with `PATH_ESCAPES_WORKSPACE` instead of being followed. + +use std::collections::BTreeSet; +use std::ffi::OsString; +use std::fs; +use std::io; +use std::os::unix::fs::MetadataExt; +use std::path::Path; + +use super::messages::{self as m, fill, or}; +use super::paths::{self, absent}; +use super::{CD_DONE, Ctx, PWD_USED, file_text}; + +#[derive(Clone, Copy)] +enum Text { + Is(&'static str), + Has(&'static [&'static str]), +} + +#[derive(Clone, Copy)] +enum Bits { + Is(u32), + Has(u32), + Lacks(u32), +} + +#[derive(Clone, Copy)] +enum Kind { + At(&'static str), + AtHome, + Exists(&'static str, bool), + Gone(&'static str), + Content(&'static str, Text), + Same(&'static str, &'static str), + Moved(&'static str, &'static str), + Mode(&'static str, Bits), + Marker(&'static str), +} + +/// The texts are empty when the default applies. +#[derive(Clone, Copy)] +pub struct Check { + kind: Kind, + msg: &'static str, + missing: &'static str, +} + +#[rustfmt::skip] +mod vocabulary { + use super::*; + + const fn new(kind: Kind) -> Check { Check { kind, msg: "", missing: "" } } + + /// The player stands in this directory (compared canonically). + pub const fn at(path: &'static str) -> Check { new(Kind::At(path)) } + pub const fn at_home() -> Check { new(Kind::AtHome) } + pub const fn is_file(path: &'static str) -> Check { new(Kind::Exists(path, false)) } + pub const fn is_dir(path: &'static str) -> Check { new(Kind::Exists(path, true)) } + /// Nothing remains here, whatever it was, not even a dangling symlink. + pub const fn no_file(path: &'static str) -> Check { new(Kind::Gone(path)) } + /// The whole content equals `text`; a block, as in `file`, ends with a newline. + pub const fn content_is(path: &'static str, text: &'static str) -> Check { new(Kind::Content(path, Text::Is(text))) } + pub const fn contains(path: &'static str, parts: &'static [&'static str]) -> Check { new(Kind::Content(path, Text::Has(parts))) } + /// `dst` is an exact copy of `src`: names, bytes and dotfiles; symlinks compared, never followed. + /// `.msg` is the text when it differs, `.missing` when `dst` is missing. + pub const fn same(src: &'static str, dst: &'static str) -> Check { new(Kind::Same(src, dst)) } + /// `src` is gone and `dst` exists. `.msg` is the text when `src` is still there, `.missing` + /// when `dst` is missing. + pub const fn moved(src: &'static str, dst: &'static str) -> Check { new(Kind::Moved(src, dst)) } + /// The exact permission bits of a file or a directory. + pub const fn mode_is(path: &'static str, mode: u32) -> Check { new(Kind::Mode(path, Bits::Is(mode))) } + /// Every one of these permission bits is set. + pub const fn mode_has(path: &'static str, bits: u32) -> Check { new(Kind::Mode(path, Bits::Has(bits))) } + /// None of these permission bits is set. + pub const fn mode_lacks(path: &'static str, bits: u32) -> Check { new(Kind::Mode(path, Bits::Lacks(bits))) } + /// The player has run `pwd` (the shell wrapper leaves the evidence). + pub const fn pwd_used(msg: &'static str) -> Check { new(Kind::Marker(PWD_USED)).msg(msg) } + /// The level's `cd` drill was done; needs `cd` clauses, which record it. + pub const fn cd_done(msg: &'static str) -> Check { new(Kind::Marker(CD_DONE)).msg(msg) } + + impl Check { + /// The text on failure, instead of the default. + pub const fn msg(self, msg: &'static str) -> Self { Self { msg, ..self } } + /// The text when the file the check reads (or the copy, destination) does not exist. + pub const fn missing(self, missing: &'static str) -> Self { Self { missing, ..self } } + } +} +pub use vocabulary::*; + +impl Check { + #[cfg(test)] + pub fn paths(&self) -> Vec<&'static str> { + match self.kind { + Kind::At(p) + | Kind::Exists(p, _) + | Kind::Gone(p) + | Kind::Content(p, _) + | Kind::Mode(p, _) => vec![p], + Kind::Same(a, b) | Kind::Moved(a, b) => vec![a, b], + Kind::AtHome | Kind::Marker(_) => vec![], + } + } + + /// The texts a level wrote for a failure. + #[cfg(test)] + pub fn texts(&self) -> Vec<&'static str> { + [self.msg, self.missing].into_iter().filter(|text| !text.is_empty()).collect() + } + + /// The directory an `at` check wants the player in. + #[cfg(test)] + pub fn target_dir(&self) -> Option<&'static str> { + match self.kind { + Kind::At(path) => Some(path), + _ => None, + } + } + + pub fn run(&self, ctx: &Ctx) -> Result<(), String> { + let msg = |default: String| or(self.msg, default); + let missing = |default: String| or(self.missing, default); + match self.kind { + Kind::At(rel) => { + let (target, cwd) = + (ctx.path(rel, false)?, ctx.cwd.as_ref().ok_or(m::CWD_MISSING)?); + paths::same(cwd, &target) + .then_some(()) + .ok_or_else(|| msg(m::WRONG_DIRECTORY.into())) + } + Kind::AtHome => { + let cwd = ctx.cwd.as_ref().ok_or(m::CWD_MISSING)?; + paths::same(cwd, ctx.home) + .then_some(()) + .ok_or_else(|| msg(fill(m::NOT_AT_HOME, cwd.display()))) + } + Kind::Exists(rel, dir) => find(ctx, rel, dir)?.map(drop).ok_or_else(|| { + msg(fill([m::FILE_NOT_EXISTS, m::DIR_NOT_EXISTS][usize::from(dir)], rel)) + }), + Kind::Gone(rel) => match probe(&ctx.path(rel, true)?, false)? { + None => Ok(()), + Some(_) => Err(msg(fill(m::FILE_STILL_EXISTS, rel))), + }, + Kind::Content(rel, want) => { + let text = read(ctx, rel, self.missing)?; + let holds = match want { + Text::Is(expected) => text == file_text(expected), + Text::Has(parts) => parts.iter().all(|part| text.contains(part)), + }; + holds.then_some(()).ok_or_else(|| msg(m::FILE_CONTENT_MISMATCH.into())) + } + Kind::Same(src, dst) => { + let (from, to) = (ctx.path(src, false)?, ctx.path(dst, false)?); + if probe(&from, true)?.is_none() { + return Err(fill(m::COPY_SOURCE_MISSING, src)); + } + if probe(&to, true)?.is_none() { + return Err(missing(fill(m::DEST_MISSING, dst))); + } + match same_tree(&from, &to) { + Ok(equal) => { + equal.then_some(()).ok_or_else(|| msg(m::COPY_CONTENT_MISMATCH.into())) + } + Err(error) => Err(fill(m::COMPARE_FAILED, error)), + } + } + Kind::Moved(src, dst) => { + let (from, to) = (ctx.path(src, false)?, ctx.path(dst, false)?); + if probe(&from, true)?.is_some() { + return Err(msg(fill(m::MOVE_SOURCE_EXISTS, src))); + } + probe(&to, true)?.map(drop).ok_or_else(|| missing(fill(m::DEST_MISSING, dst))) + } + Kind::Mode(rel, want) => { + let meta = probe(&ctx.path(rel, false)?, true)?; + let meta = meta.ok_or_else(|| missing(fill(m::FILE_NOT_EXISTS, rel)))?; + let actual = meta.mode() & 0o7777; + let holds = match want { + Bits::Is(mode) => actual == mode, + Bits::Has(bits) => actual & bits == bits, + Bits::Lacks(bits) => actual & bits == 0, + }; + holds.then_some(()).ok_or_else(|| msg(m::PERMISSION_WRONG.into())) + } + Kind::Marker(name) => { + ctx.marker(name).exists().then_some(()).ok_or_else(|| msg(String::new())) + } + } + } +} + +/// What is at `path`: nothing is a normal answer, an unreadable parent is an error to report. +fn probe(path: &Path, follow: bool) -> Result<Option<fs::Metadata>, String> { + match if follow { fs::metadata(path) } else { fs::symlink_metadata(path) } { + Ok(meta) => Ok(Some(meta)), + Err(error) if absent(&error) => Ok(None), + Err(error) => Err(fill(m::FILESYSTEM_ERROR, error)), + } +} + +/// A regular file or a directory (as asked) at a declared path. +fn find(ctx: &Ctx, rel: &str, dir: bool) -> Result<Option<fs::Metadata>, String> { + let meta = probe(&ctx.path(rel, false)?, true)?; + Ok(meta.filter(|meta| meta.is_dir() == dir && (meta.is_dir() || meta.is_file()))) +} + +fn read(ctx: &Ctx, rel: &str, missing: &str) -> Result<String, String> { + let path = ctx.path(rel, false)?; + if !probe(&path, true)?.is_some_and(|meta| meta.is_file()) { + return Err(or(missing, fill(m::FILE_NOT_EXISTS, rel))); + } + let unreadable = |error: &dyn std::fmt::Display| fill(m::FILE_READ_ERROR, error); + let bytes = fs::read(&path).map_err(|error| unreadable(&error))?; + String::from_utf8(bytes).map_err(|error| unreadable(&error)) +} + +/// Deep comparison without following symlinks: a link equals a link with the same target, a file +/// a file with the same bytes, a directory one with the same entry names and equal entries. +fn same_tree(a: &Path, b: &Path) -> io::Result<bool> { + let (kind_a, kind_b) = + (fs::symlink_metadata(a)?.file_type(), fs::symlink_metadata(b)?.file_type()); + if kind_a.is_symlink() || kind_b.is_symlink() { + return Ok(kind_a.is_symlink() + && kind_b.is_symlink() + && fs::read_link(a)? == fs::read_link(b)?); + } + if kind_a.is_file() || kind_b.is_file() { + return Ok(kind_a.is_file() && kind_b.is_file() && fs::read(a)? == fs::read(b)?); + } + let names = |dir: &Path| -> io::Result<BTreeSet<OsString>> { + fs::read_dir(dir)?.map(|entry| entry.map(|entry| entry.file_name())).collect() + }; + if !(kind_a.is_dir() && kind_b.is_dir()) || names(a)? != names(b)? { + return Ok(false); + } + for name in names(a)? { + if !same_tree(&a.join(&name), &b.join(&name))? { + return Ok(false); + } + } + Ok(true) +} + +#[cfg(test)] +mod tests { + use std::os::unix::fs::{PermissionsExt, symlink}; + + use super::*; + use crate::game::testing::{World, err}; + + #[test] + fn at_compares_canonical_directories_and_notices_a_deleted_one() { + let w = World::new(); + let ctx = |dir: &str| w.ctx(Some(w.root().join(dir))); + fs::create_dir_all(w.root().join("a/b")).unwrap(); + fs::create_dir_all(w.root().join("other/b")).unwrap(); + assert_eq!(at("a/b").run(&ctx("a/b")), Ok(())); + assert_eq!( + at("a/b").run(&ctx("other/b")), + err(m::WRONG_DIRECTORY), + "a same-named directory elsewhere" + ); + assert_eq!(at("a/b").msg("custom").run(&ctx("")), err("custom")); + assert_eq!(at("a/b").run(&w.ctx(None)), err(m::CWD_MISSING)); + assert_eq!(at_home().run(&w.ctx(Some(w.home.clone()))), Ok(())); + assert!(at_home().run(&ctx("")).unwrap_err().contains(&w.root().display().to_string())); + assert_eq!(at_home().run(&w.ctx(None)), err(m::CWD_MISSING)); + } + + #[test] + fn a_path_through_a_symlink_fails_and_gone_means_nothing_remains() { + let w = World::new(); + w.put("real/file.txt", "x"); + symlink(w.root().join("real"), w.root().join("link")).unwrap(); + for check in + [is_file("link/file.txt"), content_is("link/file.txt", "x"), no_file("link/file.txt")] + { + assert_eq!(w.run(check), err(m::PATH_ESCAPES_WORKSPACE)); + } + assert!(w.run(is_file("real/file.txt")).is_ok() && w.run(is_dir("real")).is_ok()); + assert_eq!(w.run(is_file("real")), err("Soubor 'real' neexistuje")); + assert_eq!(w.run(no_file("real/file.txt")), err("Soubor 'real/file.txt' stále existuje")); + assert!(w.run(no_file("real")).is_err(), "a directory is something too"); + symlink(w.root().join("nowhere"), w.root().join("dangling")).unwrap(); + assert!(w.run(no_file("dangling")).is_err()); + fs::remove_file(w.root().join("dangling")).unwrap(); + assert!(w.run(no_file("dangling")).is_ok() && w.run(no_file("never-was")).is_ok()); + } + + #[test] + fn content_checks_are_exact_and_explain() { + let w = World::new(); + w.put("log.txt", "one\ntwo\n"); + assert_eq!(w.run(content_is("log.txt", "one\ntwo\n")), Ok(())); + // A block expectation ends with a newline like the file. + let block = r#" + one + two + "#; + assert_eq!(w.run(content_is("log.txt", block)), Ok(())); + for wrong in ["one\ntwo", "one\n", "one\ntwo\n\n", " one\ntwo\n"] { + assert_eq!(w.run(content_is("log.txt", wrong)), err(m::FILE_CONTENT_MISMATCH)); + } + assert_eq!(w.run(contains("log.txt", &["two", "one"])), Ok(())); + assert_eq!(w.run(contains("log.txt", &["three"]).msg("lacks")), err("lacks")); + assert_eq!(w.run(contains("none.txt", &["x"]).missing("write it")), err("write it")); + fs::write(w.root().join("binary"), [0xff, 0xfe]).unwrap(); + assert!(w.run(contains("binary", &["x"])).unwrap_err().starts_with("Nelze přečíst soubor")); + } + + #[test] + fn same_compares_trees_without_following_links() { + let w = World::new(); + for side in ["src", "dst"] { + for (name, content) in [("a.txt", "a"), (".hidden", "h"), ("sub/b.txt", "b")] { + w.put(&format!("{side}/{name}"), content); + } + } + symlink("a.txt", w.root().join("src/link")).unwrap(); + symlink("..", w.root().join("src/up")).unwrap(); + assert!(w.run(same("src", "dst")).is_err(), "links only on one side"); + symlink("a.txt", w.root().join("dst/link")).unwrap(); + symlink("..", w.root().join("dst/up")).unwrap(); + assert_eq!(w.run(same("src", "dst")), Ok(())); + fs::write(w.root().join("dst/.hidden"), "changed").unwrap(); + assert_eq!(w.run(same("src", "dst").msg("differs")), err("differs")); + assert_eq!(w.run(same("nope", "dst")), err(&fill(m::COPY_SOURCE_MISSING, "nope"))); + assert_eq!(w.run(same("src", "nope")), err("Cílový soubor 'nope' neexistuje.")); + assert_eq!(w.run(same("src", "nope").msg("differs").missing("copy it")), err("copy it")); + } + + #[test] + fn moved_and_modes() { + let w = World::new(); + w.put("old.txt", "x"); + assert_eq!(w.run(moved("old.txt", "new.txt").msg("still there")), err("still there")); + fs::rename(w.root().join("old.txt"), w.root().join("new.txt")).unwrap(); + assert_eq!(w.run(moved("old.txt", "new.txt")), Ok(())); + let lost = moved("old.txt", "other.txt").msg("still there").missing("lost"); + assert_eq!(w.run(lost), err("lost")); + let set = |name: &str, mode| { + fs::set_permissions(w.root().join(name), fs::Permissions::from_mode(mode)).unwrap() + }; + fs::create_dir(w.root().join("dir")).unwrap(); + set("new.txt", 0o754); + set("dir", 0o750); + let mode = |check| w.run(check).is_ok(); + assert!( + mode(mode_is("new.txt", 0o754)) + && mode(mode_has("new.txt", 0o100)) + && mode(mode_lacks("new.txt", 0o003)) + && mode(mode_is("dir", 0o750)) + ); + assert!( + !mode(mode_is("new.txt", 0o755)) + && !mode(mode_has("new.txt", 0o002)) + && !mode(mode_lacks("new.txt", 0o004)) + && !mode(mode_is("dir", 0o755)) + ); + assert_eq!(w.run(mode_is("nothing", 0o755)), err("Soubor 'nothing' neexistuje")); + assert_eq!(w.run(mode_is("nothing", 0o755).missing("none")), err("none")); + } +} diff --git a/src/game/messages.rs b/src/game/messages.rs new file mode 100644 index 0000000..470f157 --- /dev/null +++ b/src/game/messages.rs @@ -0,0 +1,40 @@ +//! Czech texts shared by checks, answers and the runtime. A `{}` is filled with [`fill`]. + +pub const ANSWER_REQUIRED: &str = "Musíte zadat odpověď: shellgame submit <odpověď>"; +pub const ANSWER_FORMAT: &str = "Odpověď nemá očekávaný formát."; +pub const CORRECT: &str = "Správně!"; +pub const INCORRECT: &str = "Nesprávně."; + +pub const WRONG_DIRECTORY: &str = + "Nejste v cílovém adresáři. Dokončete přesun podle zadání a ověřte svou polohu příkazem 'pwd'."; +pub const NOT_AT_HOME: &str = "Nejste doma. Jste v: {}"; +pub const FILE_NOT_EXISTS: &str = "Soubor '{}' neexistuje"; +pub const FILE_STILL_EXISTS: &str = "Soubor '{}' stále existuje"; +pub const DIR_NOT_EXISTS: &str = "Adresář '{}' neexistuje"; +pub const FILE_CONTENT_MISMATCH: &str = "Obsah souboru neodpovídá očekávání"; +pub const FILE_READ_ERROR: &str = + "Nelze přečíst soubor: {}. Obnovte level příkazem 'shellgame reset'."; +pub const FILESYSTEM_ERROR: &str = "Chyba při práci se soubory: {}"; +pub const COPY_SOURCE_MISSING: &str = + "Zdrojový soubor '{}' chybí (možná jste ho přesunuli místo zkopírování?)."; +pub const DEST_MISSING: &str = "Cílový soubor '{}' neexistuje."; +pub const COPY_CONTENT_MISMATCH: &str = "Obsah zkopírovaného souboru neodpovídá originálu."; +pub const MOVE_SOURCE_EXISTS: &str = + "Původní soubor '{}' stále existuje (možná jste ho zkopírovali místo přesunutí?)."; +pub const COMPARE_FAILED: &str = "Chyba při kontrole souborů: {}"; +pub const PERMISSION_WRONG: &str = "Oprávnění nejsou nastavena správně."; +pub const CWD_MISSING: &str = "Aktuální adresář už neexistuje (nejspíš jste ho smazali). Příkaz 'shellgame reset' vás vrátí na start levelu."; +pub const PATH_ESCAPES_WORKSPACE: &str = "Cesta úkolu vede přes symbolický odkaz mimo pracovní prostor. Level obnovíte příkazem 'shellgame reset'."; +/// Printed under every blocked `cd`, so a player can always recover. +pub const CD_RECOVERY_TIP: &str = + "Ztratili jste se? Příkaz 'shellgame reset' vás vrátí na start levelu."; + +/// Put `value` where the template has `{}`. +pub fn fill(template: &str, value: impl std::fmt::Display) -> String { + template.replacen("{}", &value.to_string(), 1) +} + +/// The text a level wrote, or the default when it wrote none (an empty text). +pub fn or(custom: &str, default: impl Into<String>) -> String { + if custom.is_empty() { default.into() } else { custom.to_owned() } +} diff --git a/src/game/mod.rs b/src/game/mod.rs new file mode 100644 index 0000000..db70762 --- /dev/null +++ b/src/game/mod.rs @@ -0,0 +1,375 @@ +//! The game engine: course types, preparing a level, judging work and strict `cd` rules. +//! +//! A level is a plain `const` literal in `crate::levels`; this module is everything that gives its +//! fields meaning. The runtime API, all keyed by [`Pos`] (`"S.L"`, levels counted from 1): +//! +//! * [`prepare`]: clear markers, repair the section root, apply the level's setup; +//! * [`start_dir`]: where the player is placed (the section root unless the level says otherwise); +//! * [`Ctx::new`]: what judging needs to know (workspace, section root, canonical cwd, logical home); +//! * [`Level::judge`]: the level's checks in order, then its answer; `Err` is the text to show; +//! * [`cd::before`] and [`cd::after`]: the strict-`cd` hook around every `cd`, with a [`CdMove`]; +//! * [`dedent`]: texts, intros and solutions are indented raw strings. +//! +//! The authoring vocabulary (`exact`, `at`, `file`, `cd::exact`, ...) lives in the submodules and is +//! re-exported here as one-line `const fn`s; the tables are `#[rustfmt::skip]` so they read as such. +//! +//! Evidence the shell witnesses lives in marker files in the workspace root: [`PWD_USED`] (written +//! by the shell wrapper), [`CD_DONE`] and custom `.cd_*` tracking files (`Ctx::marker`). Preparing +//! any level removes all of them, so evidence always belongs to the level being played. + +pub mod answer; +pub mod cd; +pub mod check; +pub mod messages; +pub mod paths; +pub mod setup; + +use std::fmt; +use std::fs::{self, OpenOptions}; +use std::io::{self, Write}; +use std::os::unix::fs::OpenOptionsExt; +use std::path::{Path, PathBuf}; +use std::str::FromStr; + +pub use answer::*; +pub use cd::CdMove; +pub use check::*; +pub use setup::*; + +use crate::levels::COURSE; + +pub const PWD_USED: &str = ".pwd_used"; +pub const CD_DONE: &str = ".cd_done"; + +#[derive(Clone, Copy)] +pub struct Section { + /// Without a number: the UI adds "Sekce N". + pub title: &'static str, + /// Markdown pages separated by a line `---`, shown when entering the section and never saved. + pub intro: &'static str, + pub levels: &'static [Level], +} + +#[derive(Clone, Copy)] +pub struct Level { + pub title: &'static str, + /// Markdown, an indented raw string (see [`dedent`]). + pub text: &'static str, + /// Plain text, shown one at a time. + pub hints: &'static [&'static str], + /// The player's start directory relative to the section root. + pub start: &'static str, + pub setup: &'static [Setup], + pub answer: Answer, + pub check: &'static [Check], + pub cd: &'static [cd::Cd], + /// A shell script that completes the level, an indented raw string (see [`dedent`]). + pub solution: &'static str, + /// One line, or an indented raw string (see [`dedent`]). + pub success: &'static str, +} + +/// The defaults: `Level { title: "..", ..LEVEL }`. +pub const LEVEL: Level = Level { + title: "", + text: "", + hints: &[], + start: ".", + setup: &[], + answer: NO_ANSWER, + check: &[], + cd: &[], + solution: "", + success: messages::CORRECT, +}; + +impl Level { + /// The checks first, in order (the first failure wins), then the answer. Without an answer + /// (`None`) the level passes when it takes none. A workspace or section directory that is a + /// symlink is nobody's fault to judge: `PATH_ESCAPES_WORKSPACE`. + pub fn judge(&self, ctx: &Ctx, answer: Option<&str>) -> Result<(), String> { + ctx.path("", false)?; + self.check.iter().try_for_each(|check| check.run(ctx))?; + self.answer.judge(answer) + } +} + +/// A position in the course: `"S.L"`, section from 0 (the welcome), level from 1. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct Pos { + pub section: usize, + pub level: usize, +} + +impl Pos { + pub fn section(self) -> &'static Section { + &COURSE[self.section] + } + + pub fn level(self) -> &'static Level { + &self.section().levels[self.level - 1] + } + + /// Every position, in course order. + pub fn all() -> impl Iterator<Item = Pos> { + COURSE + .iter() + .enumerate() + .flat_map(|(section, s)| (1..=s.levels.len()).map(move |level| Pos { section, level })) + } + + pub fn next(self) -> Option<Pos> { + Pos::all().skip_while(|pos| *pos != self).nth(1) + } + + /// `level-S` below the workspace. + pub fn section_dir(self, workspace: &Path) -> PathBuf { + paths::join(workspace, &self.rel()) + } + + fn rel(self) -> String { + format!("level-{}", self.section) + } +} + +impl fmt::Display for Pos { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}.{}", self.section, self.level) + } +} + +impl FromStr for Pos { + type Err = String; + + /// `"S.L"`, or `"S"` for the first level of a section. + fn from_str(text: &str) -> Result<Pos, String> { + let (section, level) = text.split_once('.').unwrap_or((text, "1")); + let number = |digits: &str| { + let valid = !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()); + valid.then(|| digits.parse().ok()).flatten() + }; + let pos = number(section).zip(number(level)).map(|(section, level)| Pos { section, level }); + pos.filter(|pos| Pos::all().any(|known| known == *pos)) + .ok_or_else(|| format!("neznámá pozice {text:?} (očekávám S.L, například 3.2)")) + } +} + +/// What the player of a level sees around the files: who, where and what the shell reported. +#[derive(Clone, Debug)] +pub struct Ctx<'a> { + pub workspace: &'a Path, + pub section_root: PathBuf, + /// The player's directory, canonical; `None` when it no longer exists. + pub cwd: Option<PathBuf>, + /// The logical `$HOME`. + pub home: &'a Path, +} + +impl<'a> Ctx<'a> { + pub fn new(workspace: &'a Path, pos: Pos, cwd: Option<PathBuf>, home: &'a Path) -> Self { + Self { workspace, section_root: pos.section_dir(workspace), cwd, home } + } + + /// A marker file in the workspace root; custom ones are named `.cd_*`. + pub fn marker(&self, name: &str) -> PathBuf { + self.workspace.join(name) + } + + /// Write a marker, never through a symlink the player left in its place. + pub fn mark(&self, name: &str, content: &str) -> io::Result<()> { + let flags = rustix::fs::OFlags::NOFOLLOW.bits() as i32; + let mut options = OpenOptions::new(); + options.write(true).create(true).truncate(true).custom_flags(flags); + options.open(self.marker(name))?.write_all(content.as_bytes()) + } + + /// A declared path below the section root; refused (with the player's text) when it passes + /// through a symlink. + fn path(&self, rel: &str, leaf_link_ok: bool) -> Result<PathBuf, String> { + paths::resolve(&self.section_root, rel, leaf_link_ok) + .map_err(|_| messages::PATH_ESCAPES_WORKSPACE.to_owned()) + } +} + +/// Put the level into its starting state: remove all markers, repair the section root (a file or a +/// symlink in its place is removed, a directory the player locked is made usable) and apply the +/// setup. Refuses a workspace that is a symlink; the caller makes sure it is a usable directory. +pub fn prepare(pos: Pos, workspace: &Path) -> io::Result<()> { + let root = paths::resolve(workspace, &pos.rel(), true)?; + // A workspace that does not exist yet has no markers. + for entry in fs::read_dir(workspace).into_iter().flatten().flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + if name == PWD_USED || name.starts_with(".cd_") { + remove_tree(&entry.path())?; + } + } + setup::ensure_dir(&root)?; + setup::apply(&root, pos.level().setup) +} + +/// Where the player is placed when the level starts. +pub fn start_dir(pos: Pos, workspace: &Path) -> PathBuf { + paths::join(&pos.section_dir(workspace), pos.level().start) +} + +/// Undo the indentation of a raw string written in code: its first line (after the opening +/// delimiter) is empty, its last holds only the closing delimiter's indentation, and the rest +/// loses the spaces they all share. A string that does not start with a newline stays as it is. +pub fn dedent(raw: &str) -> String { + let Some(body) = raw.strip_prefix('\n') else { + return raw.to_owned(); + }; + let mut lines: Vec<&str> = body.split('\n').collect(); + if lines.last().is_some_and(|last| last.trim().is_empty()) { + lines.pop(); + } + let indent = lines + .iter() + .filter(|line| !line.trim().is_empty()) + .map(|line| line.len() - line.trim_start_matches(' ').len()) + .min() + .unwrap_or(0); + let lines: Vec<&str> = lines.iter().map(|line| line.get(indent..).unwrap_or("")).collect(); + lines.join("\n") +} + +/// The content a `file` writes and a `content_is` expects: an indented block (it starts with a +/// newline) is dedented and ends with a newline like a text file; any other text is as it is. +pub(crate) fn file_text(text: &str) -> String { + if text.starts_with('\n') { dedent(text) + "\n" } else { text.to_owned() } +} + +#[cfg(test)] +pub(crate) mod testing { + use super::*; + + /// A failure with this text. + pub fn err(message: &str) -> Result<(), String> { + Err(message.to_owned()) + } + + /// A workspace (its path has a space, as in real play) with section 1's directory and a home. + pub struct World { + _tmp: tempfile::TempDir, + pub ws: PathBuf, + pub home: PathBuf, + } + + impl World { + pub fn new() -> World { + let tmp = tempfile::tempdir().unwrap(); + let base = fs::canonicalize(tmp.path()).unwrap(); + let (ws, home) = (base.join("shellgame workspace"), base.join("home/ada")); + fs::create_dir_all(ws.join("level-1")).unwrap(); + fs::create_dir_all(&home).unwrap(); + World { ws, home, _tmp: tmp } + } + + pub fn root(&self) -> PathBuf { + self.ws.join("level-1") + } + + /// The context of section 1 with the player at `cwd`. + pub fn ctx(&self, cwd: Option<PathBuf>) -> Ctx<'_> { + Ctx::new(&self.ws, Pos { section: 1, level: 1 }, cwd, &self.home) + } + + /// Write a file below section 1's directory. + pub fn put(&self, rel: &str, content: &str) { + let path = self.root().join(rel); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, content).unwrap(); + } + + /// Run a check with the player at section 1's directory. + pub fn run(&self, check: Check) -> Result<(), String> { + check.run(&self.ctx(Some(self.root()))) + } + + /// Prepare a level of the course and put the player at its start. + pub fn start(&self, pos: Pos) -> Ctx<'_> { + prepare(pos, &self.ws).unwrap(); + let start = start_dir(pos, &self.ws); + assert!(start.is_dir(), "{pos}: the start {start:?} must exist"); + Ctx::new(&self.ws, pos, Some(start), &self.home) + } + } +} + +#[cfg(test)] +mod tests { + use std::os::unix::fs::symlink; + + use super::testing::World; + use super::*; + + #[test] + fn positions_parse_print_and_walk_the_course() { + let all: Vec<Pos> = Pos::all().collect(); + assert_eq!(all[0], Pos { section: 0, level: 1 }); + for (index, pos) in all.iter().enumerate() { + assert_eq!(pos.to_string().parse(), Ok(*pos)); + assert_eq!(pos.next(), all.get(index + 1).copied()); + } + assert_eq!("2".parse(), Ok(Pos { section: 2, level: 1 })); + assert!( + ["", "x", "1.", ".1", "1.0", "1.2.3", "+1.1", " 1.1", "99", "1.99"] + .iter() + .all(|bad| bad.parse::<Pos>().is_err()) + ); + } + + #[test] + fn indented_raw_strings_are_dedented() { + assert_eq!(dedent("\n a\n\n b\n "), "a\n\n b"); + assert_eq!(dedent("\n a\n \n b\n "), "a\n \nb"); + assert_eq!([dedent("\n x\n "), dedent("one line")], ["x", "one line"]); + } + + #[test] + fn preparing_removes_every_marker_and_never_follows_a_symlink() { + let (w, outside) = (World::new(), tempfile::tempdir().unwrap()); + // A level of section 1 that sets files up, so that writing through a link would show. + let sets_up = |pos: &Pos| pos.section == 1 && !pos.level().setup.is_empty(); + let (pos, ws) = (Pos::all().find(sets_up).unwrap(), w.ws.clone()); + let markers = [PWD_USED, CD_DONE, ".cd_custom"]; + for name in markers.iter().chain(&[".not-a-marker"]) { + fs::write(ws.join(name), "").unwrap(); + } + fs::create_dir(ws.join(".cd_a_directory")).unwrap(); + prepare(pos, &ws).unwrap(); + assert!(markers.iter().all(|name| !ws.join(name).exists())); + assert!(!ws.join(".cd_a_directory").exists() && ws.join(".not-a-marker").exists()); + + // A marker the player replaced by a link is never written through, and preparing removes it. + let precious = outside.path().join("precious"); + fs::write(&precious, "mine").unwrap(); + symlink(&precious, ws.join(CD_DONE)).unwrap(); + assert!(w.ctx(None).mark(CD_DONE, "x").is_err()); + prepare(pos, &ws).unwrap(); + assert_eq!( + (fs::read_to_string(&precious).unwrap(), ws.join(CD_DONE).exists()), + ("mine".into(), false) + ); + + // A symlinked workspace is refused; a symlinked section directory is replaced, and nothing is + // written through either. + symlink(&ws, ws.with_file_name("link")).unwrap(); + assert!(prepare(pos, &ws.with_file_name("link")).is_err()); + fs::remove_dir_all(w.root()).unwrap(); + symlink(outside.path(), w.root()).unwrap(); + prepare(pos, &ws).unwrap(); + assert!(!w.root().is_symlink() && w.root().is_dir()); + assert_eq!( + fs::read_dir(outside.path()).unwrap().count(), + 1, + "nothing was written through the link" + ); + // Judging never follows one either. + fs::remove_dir_all(w.root()).unwrap(); + symlink(outside.path(), w.root()).unwrap(); + const ANY: Level = Level { answer: exact("a"), ..LEVEL }; + let refused = ANY.judge(&w.ctx(None), Some("a")); + assert_eq!(refused, Err(messages::PATH_ESCAPES_WORKSPACE.into())); + } +} diff --git a/src/game/paths.rs b/src/game/paths.rs new file mode 100644 index 0000000..6c1b014 --- /dev/null +++ b/src/game/paths.rs @@ -0,0 +1,130 @@ +//! Declared paths are relative to the section root and cannot leave it; paths the player is at +//! or is going to are compared after resolving symlinks. + +use std::fs; +use std::io; +use std::path::{Component, Path, PathBuf}; + +/// Nothing there (a missing path or parent), as opposed to a real error. +pub fn absent(error: &io::Error) -> bool { + matches!(error.kind(), io::ErrorKind::NotFound | io::ErrorKind::NotADirectory) +} + +fn refused(what: String) -> io::Error { + io::Error::new(io::ErrorKind::InvalidInput, what) +} + +/// The components of a declared path (`""` and `"."` are the root). Absolute paths, `..` and NUL +/// are refused. +pub fn split(rel: &str) -> io::Result<Vec<&str>> { + if rel.starts_with('/') || rel.contains('\0') || rel.split('/').any(|part| part == "..") { + return Err(refused(format!("není prostá relativní cesta: {rel:?}"))); + } + Ok(rel.split('/').filter(|part| !matches!(*part, "" | ".")).collect()) +} + +/// `rel` below `root`, lexically; a refused path is the root itself. +pub fn join(root: &Path, rel: &str) -> PathBuf { + split(rel) + .unwrap_or_default() + .into_iter() + .fold(root.to_path_buf(), |path, part| path.join(part)) +} + +/// A declared path below `root` that no symlink leads out of: the root and every component (the +/// last one only unless `leaf_link_ok`) must not be a symlink. Nothing is ever followed. +pub fn resolve(root: &Path, rel: &str, leaf_link_ok: bool) -> io::Result<PathBuf> { + let parts = split(rel)?; + let is_link = |path: &Path| fs::symlink_metadata(path).is_ok_and(|meta| meta.is_symlink()); + let mut path = root.to_path_buf(); + if is_link(&path) { + return Err(refused(format!("{} je symbolický odkaz", path.display()))); + } + for (index, part) in parts.iter().enumerate() { + path.push(part); + if !(leaf_link_ok && index + 1 == parts.len()) && is_link(&path) { + return Err(refused(format!("{rel:?} vede přes symbolický odkaz"))); + } + } + Ok(path) +} + +/// Resolve `.` and `..` textually, as `cd -L` does for the logical `$PWD`. +pub fn normalize(path: &Path) -> PathBuf { + let mut parts: Vec<Component> = vec![]; + for part in path.components() { + match (part, parts.last()) { + (Component::CurDir, _) | (Component::ParentDir, Some(Component::RootDir)) => {} + (Component::ParentDir, Some(Component::Normal(_))) => { + parts.pop(); + } + _ => parts.push(part), + } + } + if parts.is_empty() { PathBuf::from(".") } else { parts.into_iter().collect() } +} + +/// Canonicalize the longest existing prefix of `path` and append the rest lexically, so a +/// directory that does not exist (yet) or a workspace below a symlink still compares correctly. +pub fn canonical(path: &Path) -> PathBuf { + let parts: Vec<Component> = path.components().collect(); + let existing = (1..=parts.len()).rev().find_map(|end| { + let real = fs::canonicalize(parts[..end].iter().collect::<PathBuf>()).ok(); + real.map(|real| (end, real)) + }); + match existing { + Some((end, real)) => normalize(&real.join(parts[end..].iter().collect::<PathBuf>())), + None => normalize(path), + } +} + +pub fn same(a: &Path, b: &Path) -> bool { + canonical(a) == canonical(b) +} + +/// Whether `path` resolves to somewhere strictly inside `/tmp` (`/tmp` itself does not count): where +/// the game may delete (the workspace) and the shell may `chmod`. +pub fn inside_tmp(path: &Path) -> bool { + let (tmp, real) = (canonical(Path::new("/tmp")), canonical(path)); + real != tmp && real.starts_with(&tmp) +} + +#[cfg(test)] +mod tests { + use std::os::unix::fs::symlink; + + use super::*; + + #[test] + fn declared_paths_cannot_leave_the_root_or_pass_through_a_symlink() { + assert!(["/etc", "../x", "a/../b", "a\0b"].iter().all(|bad| split(bad).is_err())); + assert_eq!(split("a//b/./c/").unwrap(), ["a", "b", "c"]); + let (tmp, outside) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap()); + let root = tmp.path().join("root"); + fs::create_dir_all(root.join("real")).unwrap(); + symlink(outside.path(), root.join("link")).unwrap(); + symlink(outside.path(), root.join("real/leaf")).unwrap(); + assert!(resolve(&root, "real/not-yet-there", false).is_ok()); + assert!( + resolve(&root, "link/file", false).is_err() + && resolve(&root, "real/leaf", false).is_err() + ); + assert!( + resolve(&root, "real/leaf", true).is_ok(), + "a leftover link is for the caller to report" + ); + assert!(resolve(&root.join("link"), "x", false).is_err(), "the root itself"); + } + + #[test] + fn directories_compare_after_resolving_links_even_when_missing() { + let tmp = tempfile::tempdir().unwrap(); + let (real, link) = (tmp.path().join("real"), tmp.path().join("link")); + fs::create_dir(&real).unwrap(); + symlink(&real, &link).unwrap(); + assert!(same(&link, &real) && same(&link.join("missing/../new"), &real.join("new"))); + assert!(!same(&real, tmp.path())); + assert_eq!(normalize(Path::new("/a/b/../c/./d")), Path::new("/a/c/d")); + assert_eq!(normalize(Path::new("a/../../b")), Path::new("../b")); + } +} diff --git a/src/game/setup.rs b/src/game/setup.rs new file mode 100644 index 0000000..d9da7f8 --- /dev/null +++ b/src/game/setup.rs @@ -0,0 +1,384 @@ +//! What a level's files look like, applied with repair semantics. +//! +//! `shellgame reset` has to work after whatever the player did, so applying a setup makes things +//! accessible before replacing them (chmod 000 directories, read-only files), never follows a +//! symlink (a link at a declared path is replaced and its target left alone; files are created +//! with `O_EXCL`) and replaces a file where a directory belongs and the other way round. +//! Phases, whatever the order of the items: clean, dirs, files, builds, directory modes +//! (deepest first, so restricting a parent never blocks the chmod of a child). Everything +//! created or repaired ends with an explicit mode: 0o755 for directories, 0o644 for files. + +use std::cmp::Reverse; +use std::fs::{self, OpenOptions, Permissions}; +use std::io::{self, Write}; +use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}; +use std::path::Path; + +use super::file_text; +use super::paths::{absent, join, split}; + +#[derive(Clone, Copy)] +enum Body { + Text(&'static str), + Bytes(&'static [u8]), +} + +#[derive(Clone, Copy)] +enum Item { + Dir(&'static str), + File(&'static str, Body), + Clean(&'static str), + Build(fn(&Path) -> io::Result<()>), +} + +/// One declaration of a level's files; all paths are relative to the section root. +#[derive(Clone, Copy)] +pub struct Setup { + item: Item, + mode: Option<u32>, +} + +const fn new(item: Item) -> Setup { + Setup { item, mode: None } +} + +#[rustfmt::skip] +mod vocabulary { + use super::*; + + /// A directory (and its parents). + pub const fn dir(path: &'static str) -> Setup { new(Item::Dir(path)) } + /// A text file, rewritten on every prepare. A `text` that starts with a newline is an indented + /// block (see [`dedent`](crate::game::dedent)) and the file ends with a newline. + pub const fn file(path: &'static str, text: &'static str) -> Setup { new(Item::File(path, Body::Text(text))) } + /// A file with raw bytes; `&[b'x'; N]` repeats a byte. + pub const fn binary(path: &'static str, bytes: &'static [u8]) -> Setup { new(Item::File(path, Body::Bytes(bytes))) } + /// Remove whatever is at this path (file, tree or symlink) before anything is created. + pub const fn clean(path: &'static str) -> Setup { new(Item::Clean(path)) } + /// Build files with code. It gets the section root; use [`mkdir`] and [`write()`] to touch it. + pub const fn build(build: fn(&Path) -> io::Result<()>) -> Setup { new(Item::Build(build)) } + + impl Setup { + /// An exact mode for a file or directory (`0..=0o7777`); directory modes are applied last. + pub const fn mode(self, mode: u32) -> Self { Self { mode: Some(mode), ..self } } + } +} +pub use vocabulary::*; + +#[cfg(test)] +impl Setup { + pub fn path(&self) -> Option<&'static str> { + match self.item { + Item::Dir(path) | Item::File(path, _) | Item::Clean(path) => Some(path), + Item::Build(_) => None, + } + } +} + +/// A file as `file` writes it (default mode), for `build` functions; see also [`mkdir`]. +pub fn write(root: &Path, rel: &str, bytes: impl AsRef<[u8]>) -> io::Result<()> { + write_file(root, rel, bytes.as_ref(), None) +} + +/// Make the existing `root` match `items`. +pub(super) fn apply(root: &Path, items: &[Setup]) -> io::Result<()> { + let mut modes = vec![]; + for phase in 0..4 { + for setup in items { + match (phase, setup.item) { + (0, Item::Clean(path)) => clean_path(root, path)?, + (1, Item::Dir(path)) => { + mkdir(root, path)?; + modes.extend(setup.mode.map(|mode| (path, mode))); + } + (2, Item::File(path, body)) => { + let bytes = match body { + Body::Text(text) => file_text(text).into_bytes(), + Body::Bytes(bytes) => bytes.to_vec(), + }; + write_file(root, path, &bytes, setup.mode)?; + } + (3, Item::Build(build)) => build(root)?, + _ => {} + } + } + } + modes.sort_by_key(|(path, _)| Reverse(split(path).map_or(0, |parts| parts.len()))); + modes.into_iter().try_for_each(|(path, mode)| chmod(&join(root, path), mode)) +} + +/// A directory the game owns (the workspace, a section root): created when missing, made usable when +/// its owner permissions were taken away, and replaced when the player put a file or a symlink in its +/// place (the link is removed, never followed). +pub fn ensure_dir(dir: &Path) -> io::Result<()> { + match fs::symlink_metadata(dir) { + Ok(meta) if meta.is_dir() => { + if meta.mode() & 0o700 == 0o700 { + Ok(()) + } else { + chmod(dir, 0o755) + } + } + Ok(_) => { + remove_tree(dir)?; + make_dir(dir) + } + Err(error) if error.kind() == io::ErrorKind::NotFound => { + at(fs::create_dir_all(dir), "vytvořit", dir)?; + chmod(dir, 0o755) + } + Err(error) => at(Err(error), "prozkoumat", dir), + } +} + +/// Remove a file, symlink or directory tree (never following a symlink), recovering from +/// restricted permissions. A missing path is fine. +pub fn remove_tree(path: &Path) -> io::Result<()> { + let meta = match fs::symlink_metadata(path) { + Ok(meta) => meta, + Err(error) if absent(&error) => return Ok(()), + Err(error) => return at(Err(error), "prozkoumat", path), + }; + if meta.is_dir() { + let _ = fs::set_permissions(path, Permissions::from_mode(0o700)); + for entry in at(fs::read_dir(path), "vypsat", path)? { + remove_tree(&at(entry, "vypsat", path)?.path())?; + } + return at(fs::remove_dir(path), "smazat adresář", path); + } + if !meta.is_symlink() { + // chmod would follow a symlink, so a link is removed as it is. + let _ = fs::set_permissions(path, Permissions::from_mode(0o600)); + } + at(fs::remove_file(path), "smazat", path) +} + +fn at<T>(result: io::Result<T>, action: &str, path: &Path) -> io::Result<T> { + let describe = |error: io::Error| { + io::Error::new(error.kind(), format!("nelze {action} {}: {error}", path.display())) + }; + result.map_err(describe) +} + +fn chmod(path: &Path, mode: u32) -> io::Result<()> { + at(fs::set_permissions(path, Permissions::from_mode(mode)), "změnit oprávnění", path) +} + +/// Remove what is at `rel`; a symlink along the way is removed instead of what lies beyond it, +/// and directories along the way are made usable, so a parent the player locked cannot block it. +fn clean_path(root: &Path, rel: &str) -> io::Result<()> { + let parts = split(rel)?; + let mut path = root.to_path_buf(); + for (index, part) in parts.iter().enumerate() { + path.push(part); + let meta = match fs::symlink_metadata(&path) { + Ok(meta) => meta, + Err(error) if absent(&error) => return Ok(()), + Err(error) => return at(Err(error), "prozkoumat", &path), + }; + if meta.is_symlink() || index + 1 == parts.len() { + return remove_tree(&path); + } + if !meta.is_dir() { + return Ok(()); + } + if meta.mode() & 0o700 != 0o700 { + let _ = fs::set_permissions(&path, Permissions::from_mode(0o755)); + } + } + Ok(()) +} + +/// Make `rel` a real directory, component by component: a symlink or file in the way is replaced, +/// an existing directory is reset to 0o755. Also for `build` functions. +pub fn mkdir(root: &Path, rel: &str) -> io::Result<()> { + let mut path = root.to_path_buf(); + for part in split(rel)? { + path.push(part); + match fs::symlink_metadata(&path) { + Ok(meta) if meta.is_dir() => { + let _ = fs::set_permissions(&path, Permissions::from_mode(0o755)); + } + Ok(_) => { + remove_tree(&path)?; + make_dir(&path)?; + } + Err(error) if error.kind() == io::ErrorKind::NotFound => make_dir(&path)?, + Err(error) => return at(Err(error), "prozkoumat", &path), + } + } + Ok(()) +} + +fn make_dir(path: &Path) -> io::Result<()> { + at(fs::create_dir(path), "vytvořit adresář", path)?; + chmod(path, 0o755) +} + +fn write_file(root: &Path, rel: &str, bytes: &[u8], mode: Option<u32>) -> io::Result<()> { + let parts = split(rel)?; + let Some((_, parents)) = parts.split_last() else { + return at(Err(io::ErrorKind::InvalidInput.into()), "zapsat jako soubor", root); + }; + mkdir(root, &parents.join("/"))?; + let path = join(root, rel); + remove_tree(&path)?; + let open = OpenOptions::new().write(true).create_new(true).mode(0o600).open(&path); + let mut file = at(open, "vytvořit soubor", &path)?; + at(file.write_all(bytes), "zapsat", &path)?; + at( + file.set_permissions(Permissions::from_mode(mode.unwrap_or(0o644))), + "změnit oprávnění", + &path, + ) +} + +#[cfg(test)] +mod tests { + use std::os::unix::fs::symlink; + + use super::*; + + fn mode(path: &Path) -> u32 { + fs::symlink_metadata(path).unwrap().mode() & 0o7777 + } + + fn read(path: &Path) -> String { + fs::read_to_string(path).unwrap() + } + + fn chmod_all(root: &Path, mode: u32, paths: &[&str]) { + for path in paths { + fs::set_permissions(root.join(path), Permissions::from_mode(mode)).unwrap(); + } + } + + #[test] + fn builds_the_tree_with_explicit_modes_and_directory_modes_last() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let block = r#" + one + two + "#; + let items = [ + dir("a/b"), + file("a/f.txt", "x"), + file("run.sh", "#!").mode(0o755), + binary("bin", b"\x00\xff"), + build(|root| write(root, "made", "generated")), + file("block", block), + file("none", "").mode(0o000), + dir("locked").mode(0o550), + file("locked/in.txt", "y"), + dir("deep").mode(0o500), + dir("deep/er").mode(0o500), + ]; + let paths = ["a", "a/b", "a/f.txt", "run.sh", "none", "locked", "deep", "deep/er"]; + for _ in 0..2 { + apply(root, &items).unwrap(); + let modes = paths.map(|path| mode(&root.join(path))); + assert_eq!(modes, [0o755, 0o755, 0o644, 0o755, 0, 0o550, 0o500, 0o500]); + let texts = ["a/f.txt", "made", "locked/in.txt", "block"].map(|p| read(&root.join(p))); + assert_eq!(texts, ["x", "generated", "y", "one\n two\n"]); + assert_eq!(fs::read(root.join("bin")).unwrap(), [0, 0xff]); + } + chmod_all(root, 0o755, &["locked", "deep/er", "deep"]); + } + + #[test] + fn repairs_whatever_the_player_left_behind() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let items = [ + clean("junk"), + dir("d/e"), + file("d/e/f.txt", "new"), + file("g.txt", "new"), + file("h/i.txt", "new"), + ]; + apply(root, &items).unwrap(); + // A read-only edited file, a directory where a file belongs, a file where a directory + // belongs, a tree to clean and chmod 000 on directories. + fs::write(root.join("g.txt"), "edited").unwrap(); + chmod_all(root, 0o444, &["g.txt"]); + fs::remove_file(root.join("g.txt")).unwrap(); + fs::create_dir(root.join("g.txt")).unwrap(); + fs::remove_dir_all(root.join("h")).unwrap(); + fs::write(root.join("h"), "a file").unwrap(); + fs::create_dir_all(root.join("junk/deep")).unwrap(); + chmod_all(root, 0o000, &["junk/deep", "junk", "d/e", "d"]); + apply(root, &items).unwrap(); + assert!(!root.join("junk").exists()); + assert_eq!( + ["d/e/f.txt", "g.txt", "h/i.txt"].map(|path| read(&root.join(path))), + ["new", "new", "new"] + ); + assert_eq!( + [mode(&root.join("d")), mode(&root.join("d/e")), mode(&root.join("g.txt"))], + [0o755, 0o755, 0o644] + ); + } + + #[test] + fn a_symlink_at_a_declared_path_is_replaced_and_its_target_left_alone() { + let (tmp, outside) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap()); + let root = tmp.path(); + fs::write(outside.path().join("secret"), "keep").unwrap(); + for link in ["d", "gone"] { + symlink(outside.path(), root.join(link)).unwrap(); + } + symlink(outside.path().join("secret"), root.join("f.txt")).unwrap(); + apply( + root, + &[clean("gone/secret"), dir("d/sub"), file("d/x.txt", "y"), file("f.txt", "new")], + ) + .unwrap(); + assert_eq!(read(&outside.path().join("secret")), "keep"); + assert_eq!( + fs::read_dir(outside.path()).unwrap().count(), + 1, + "nothing was written through a link" + ); + assert!( + !root.join("gone").exists() + && !root.join("d").is_symlink() + && root.join("d/sub").is_dir() + ); + assert_eq!(read(&root.join("f.txt")), "new"); + } + + #[test] + fn declarations_stay_inside_and_the_game_directories_are_repaired() { + let (tmp, outside) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap()); + for bad in ["/etc/x", "../x", "a/../../x"] { + assert!( + [dir(bad), file(bad, "x"), clean(bad)].iter().all(|item| apply( + tmp.path(), + &[*item] + ) + .is_err()) + ); + } + assert!(apply(tmp.path(), &[file("", "x")]).is_err(), "the root is not a file"); + let root = tmp.path().join("level-1"); + ensure_dir(&root).unwrap(); + for mode_left in [0o000, 0o600] { + chmod_all(tmp.path(), mode_left, &["level-1"]); + ensure_dir(&root).unwrap(); + assert_eq!(mode(&root), 0o755); + } + fs::remove_dir(&root).unwrap(); + fs::write(&root, "a file").unwrap(); + ensure_dir(&root).unwrap(); + assert!(root.is_dir()); + // A link in its place is removed and its target left alone. + fs::remove_dir(&root).unwrap(); + fs::write(outside.path().join("keep"), "mine").unwrap(); + symlink(outside.path(), &root).unwrap(); + ensure_dir(&root).unwrap(); + assert!(!root.is_symlink() && root.is_dir()); + assert_eq!(read(&outside.path().join("keep")), "mine"); + assert_eq!(fs::read_dir(outside.path()).unwrap().count(), 1); + } +} diff --git a/src/levels/mod.rs b/src/levels/mod.rs new file mode 100644 index 0000000..9c02ceb --- /dev/null +++ b/src/levels/mod.rs @@ -0,0 +1,237 @@ +//! The whole course, in order: twelve sections from the welcome ([`s00_welcome`]) to searching +//! ([`s11_search`]), listed once in [`COURSE`]. Each section's page below states its goals. +//! +//! **Audience.** First-semester students new to the shell. Players read Czech, the rest is English. +//! +//! **Structure.** A [`Section`] is a title, an intro (pages split by `---`) and its levels. A +//! [`Level`](crate::game::Level) is one struct literal: the assignment, escalating hints, the +//! files it sets up, how it is judged (an answer, checks on files and location, or both) and a +//! solution script that the tests replay. +//! +//! **Numbers are positions.** "S.L" is the L-th level of the S-th section of [`COURSE`]; there are +//! no ids, so adding, moving or removing a level or section renumbers what follows and shifts saved +//! games. Section S works in `level-S` of the workspace. +//! +//! **Progression.** Linear and cumulative: a section may use what earlier ones taught and asks for +//! nothing that a later one teaches, except a few tools its text hands over ready-made (`file *` in +//! [`s05_inspect`], `mv *.log logs/` in [`s06_copy_move`], `grep` in [`s09_redirection`]). Each +//! section file starts with its goals, and every level has to fit them. Solution scripts may use +//! plumbing (`cut`, `grep`, `wc`) that the student is not asked to type. + +pub mod s00_welcome; +pub mod s01_navigation; +pub mod s02_files; +pub mod s03_hidden; +pub mod s04_create; +pub mod s05_inspect; +pub mod s06_copy_move; +pub mod s07_wildcards; +pub mod s08_permissions; +pub mod s09_redirection; +pub mod s10_streams; +pub mod s11_search; + +use crate::game::Section; + +pub static COURSE: &[Section] = &[ + s00_welcome::SECTION, + s01_navigation::SECTION, + s02_files::SECTION, + s03_hidden::SECTION, + s04_create::SECTION, + s05_inspect::SECTION, + s06_copy_move::SECTION, + s07_wildcards::SECTION, + s08_permissions::SECTION, + s09_redirection::SECTION, + s10_streams::SECTION, + s11_search::SECTION, +]; + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::PathBuf; + + use super::COURSE; + use crate::game::paths::split; + use crate::game::testing::World; + use crate::game::{CD_DONE, CdMove, Level, Pos, cd, dedent}; + + fn levels() -> impl Iterator<Item = (Pos, &'static Level)> { + Pos::all().map(|pos| (pos, pos.level())) + } + + #[test] + fn everything_a_player_reads_is_there_and_every_level_can_be_completed() { + for section in COURSE { + assert!(!section.title.is_empty() && !section.intro.trim().is_empty()); + } + for (pos, level) in levels() { + assert!(!level.title.is_empty() && !level.text.trim().is_empty(), "{pos}"); + assert!( + !level.hints.is_empty() && level.hints.iter().all(|h| !h.trim().is_empty()), + "{pos}: hints" + ); + assert!(level.answer.is_set() || !level.check.is_empty(), "{pos}: nothing to complete"); + // Judging drops trailing slashes, so an answer that ends in one could not be given. + let (secrets, _) = level.answer.secrets_and_texts(); + assert!(secrets.iter().all(|secret| !secret.ends_with('/')), "{pos}: {secrets:?}"); + let solution = dedent(level.solution); + let last = solution.lines().last().unwrap_or_default(); + // The last line submits, through `shellgame` or an alias of it. + assert_eq!(last.split_whitespace().nth(1), Some("submit"), "{pos}: {last:?}"); + } + } + + #[test] + fn declared_paths_are_relative_without_dotdot() { + for (pos, level) in levels() { + let setup = level.setup.iter().filter_map(|setup| setup.path()); + let checks = level.check.iter().flat_map(|check| check.paths()); + let clauses = level.cd.iter().flat_map(|clause| clause.paths()); + for path in setup.chain(checks).chain(clauses).chain([level.start]) { + assert!(split(path).is_ok(), "{pos}: {path:?}"); + } + } + } + + /// A reply to a wrong answer or to a failed check never contains the answer, and a failed `at` + /// does not name where to go. + #[test] + fn a_failure_never_reveals_the_answer() { + let contains = + |text: &str, secret: &str| text.to_lowercase().contains(&secret.to_lowercase()); + let mut leaks = vec![]; + for (pos, level) in levels() { + let (secrets, mut texts) = level.answer.secrets_and_texts(); + texts.extend(level.check.iter().flat_map(|check| check.texts())); + for secret in secrets.iter().filter(|secret| secret.chars().count() >= 4) { + if let Some(text) = texts.iter().find(|text| contains(text, secret)) { + leaks.push(format!("{pos}: {secret:?} appears in {text:.60?}")); + } + } + for check in level.check { + let Some(name) = check.target_dir().and_then(|dir| split(dir).ok()?.pop()) else { + continue; + }; + if let Some(text) = check.texts().into_iter().find(|text| contains(text, name)) { + leaks.push(format!("{pos}: a failed `at` names {name:?}: {text:.60?}")); + } + } + } + assert!(leaks.is_empty(), "{}", leaks.join("\n")); + } + + #[test] + fn raw_strings_are_indented_blocks() { + let intros = COURSE.iter().map(|section| ("intro", section.intro)); + let blocks = levels().flat_map(|(_, level)| { + [("text", level.text), ("solution", level.solution), ("success", level.success)] + }); + for (what, block) in intros.chain(blocks) { + assert!(!block.contains('\t'), "{what}: tabs ({block:.40})"); + // A one-line solution or success may be a plain string; every other block starts on + // the line after its opener and ends with the closing delimiter on a line of its own. + if !matches!(what, "solution" | "success") || block.contains('\n') { + assert!( + block.starts_with('\n'), + "{what} must start on the line after the opener: {block:.40}" + ); + assert!( + block.rsplit('\n').next().unwrap().chars().all(|c| c == ' '), + "{what}: {block:.40}" + ); + let text = dedent(block); + assert!(!text.starts_with('\n') && !text.ends_with('\n'), "{what}: {text:.40}"); + } + } + } + + #[test] + fn mentions_of_levels_and_sections_match_their_position() { + let mentions = |text: &str| -> Vec<usize> { + let found = ["level-", "Sekce ", "Sekci "] + .iter() + .flat_map(|prefix| text.match_indices(prefix).map(|(at, _)| at + prefix.len())); + found + .filter_map(|at| { + text[at..].split(|c: char| !c.is_ascii_digit()).next()?.parse().ok() + }) + .collect() + }; + for (number, section) in COURSE.iter().enumerate() { + assert!( + mentions(section.intro).iter().all(|found| *found == number), + "intro of section {number}" + ); + // The intro panel's title already shows "Sekce N: <title>"; don't repeat it in the text. + assert!( + !dedent(section.intro).starts_with('#'), + "intro of section {number} starts with a heading" + ); + } + for (pos, level) in levels() { + let text = format!("{}\n{}\n{}", level.text, level.hints.join("\n"), level.success); + for found in mentions(&text) { + assert_eq!( + found, pos.section, + "{pos} mentions {found}: another section is named by its title" + ); + } + } + } + + #[test] + fn every_level_prepares_in_course_order_and_nothing_completes_at_its_start() { + let world = World::new(); + for (pos, level) in levels() { + let ctx = world.start(pos); + assert!(level.judge(&ctx, None).is_err(), "{pos}: an empty submission completes it"); + if let (false, Some(answer)) = (level.check.is_empty(), level.answer.sample()) { + assert!( + level.judge(&ctx, Some(&answer)).is_err(), + "{pos}: {answer:?} completes it without the work" + ); + } + } + } + + #[test] + fn every_cd_policy_blocks_something_and_never_traps_the_player() { + let world = World::new(); + for (pos, level) in levels().filter(|(_, level)| !level.cd.is_empty()) { + let ctx = world.start(pos); + let start = ctx.cwd.clone().unwrap(); + let named = level.cd.iter().flat_map(|clause| clause.paths()); + let places: Vec<PathBuf> = [start.clone()] + .into_iter() + .chain(named.map(|p| ctx.section_root.join(p))) + .collect(); + let decide = |place: &PathBuf, target: &str| { + cd::before(pos, &CdMove { target, pwd: place, prev: None, dest: None, ctx: &ctx }) + }; + let attempts = ["/etc", "..", "../..", "/", "", "elsewhere", "-"]; + let blocked: Vec<_> = places + .iter() + .flat_map(|place| attempts.map(move |target| (place, target))) + .filter(|(place, target)| decide(place, target).is_err()) + .collect(); + assert!(!blocked.is_empty(), "{pos}: the policy blocks nothing"); + for place in &places { + let back = decide(place, &start.to_string_lossy()); + assert_eq!( + back, + Ok(()), + "{pos}: the way back to the start is blocked from {place:?}" + ); + } + // Once the drill is done, nothing is blocked any more. + fs::write(ctx.marker(CD_DONE), "").unwrap(); + for (place, target) in blocked { + let after = decide(place, target); + assert_eq!(after, Ok(()), "{pos}: `cd {target}` from {place:?} after the drill"); + } + } + } +} diff --git a/src/levels/s00_welcome.rs b/src/levels/s00_welcome.rs new file mode 100644 index 0000000..c239bb5 --- /dev/null +++ b/src/levels/s00_welcome.rs @@ -0,0 +1,70 @@ +//! The welcome: how the game works, and the first `shellgame submit`. +//! +//! **Purpose.** Teach the game's own loop before any shell skill is needed, so that no later +//! section has to explain mechanics. It works in `level-0` and asks for no shell command. +//! +//! **Learning objectives.** After this section a student can: +//! - read the intro pages (Enter continues) and say what an assignment may ask for: a typed answer, +//! a location reached, or changed files; +//! - hand in an answer with `shellgame submit <answer>` (the warm-up wants the password it names); +//! - ask for a hint with `shellgame hint` and find the other game commands with `shellgame --help`. +//! +//! **Introduces:** `shellgame submit`, `shellgame hint`, `shellgame --help` (only pointed at).\ +//! **Relies on:** nothing; it is the first section.\ +//! **Leaves for later:** every shell command, beginning with [navigation](super::s01_navigation) +//! (where `shellgame reset` first appears); other game commands are only pointed at via `--help`. + +use crate::game::*; + +#[rustfmt::skip] +pub const SECTION: Section = Section { + title: "Vítejte v ShellGame", + intro: r#" + Vítejte v interaktivní výuce příkazové řádky! + + ### O hře + + ShellGame je série úkolů, které plníte přímo ve svém terminálu. + Hra vám zadá úkol, vy ho splníte pomocí příkazů shellu a poté odevzdáte výsledek. + Hra pak zkontroluje výsledek: vaši odpověď, adresář, ve kterém stojíte, a stav souborů. + U několika úkolů si navíc všímá, zda jste použili příkaz `pwd` nebo předepsaný `cd`. + Výsledkem může být textová odpověď, přesun do správného adresáře nebo úprava souborů a adresářů. + + ### Jak hrát + 1. Hra se skládá ze sekcí, které se věnují různým tématům. + 2. Každá sekce začíná úvodním textem. Úvod jen čtete — pokračujte stisknutím Enter. + 3. Každá sekce obsahuje několik levelů, ve kterých postupně plníte úkoly. + 4. Hotový úkol se odevzdává příkazem `shellgame submit`. Pokud úkol chce odpověď, píše se za něj + (`shellgame submit <odpověď>`); jinak se příkaz spouští bez ní. + 5. Když si nevíte rady, nápovědu zobrazí příkaz `shellgame hint`. + + ### Další příkazy + + Další příkazy (např. obnovení levelu do výchozího stavu) najdete v nápovědě: + `shellgame --help` + "#, + levels: &[ + Level { + title: "Zahřívací kolo", + text: r#" + ### Zahřívací kolo + + Toto je první testovací úkol, abychom si ověřili, že vše funguje. + + ### Úkol + Pro postup do první sekce stačí odevzdat heslo `start`. + + Odevzdejte pomocí: `shellgame submit start` + "#, + hints: &[ + "Heslo je uvedeno přímo v zadání úkolu výše.", + "Zadejte příkaz 'shellgame submit' následovaný tímto heslem.", + "Opravdu jen napište: shellgame submit start", + ], + answer: exact("start").nocase(), + solution: "shellgame submit start", + success: "Správně! Takhle se odevzdávají úkoly: 'shellgame submit', a když úkol chce odpověď, napíšete ji za něj.", + ..LEVEL + }, + ], +}; diff --git a/src/levels/s01_navigation.rs b/src/levels/s01_navigation.rs new file mode 100644 index 0000000..2b3d3ab --- /dev/null +++ b/src/levels/s01_navigation.rs @@ -0,0 +1,688 @@ +//! Navigation: knowing where you are and moving around with `pwd`, `ls` and `cd`. +//! +//! **Purpose.** Everything else happens somewhere in the filesystem: the section builds the picture +//! (tree, working directory, paths) and the commands that read and change it. +//! +//! **Learning objectives.** After this section a student can: +//! - find the working directory with `pwd` and report its name (the last path component); +//! - tell directories from files in `ls -l` (`d` or `-`) and look options up in `--help` or `man`; +//! - move relatively (`cd dir`, `cd ..`, `cd ../../..`, long paths with Tab) and through a maze; +//! - use absolute paths: one `cd /...` jump, `/` to `$HOME` segment by segment, home with `cd ~`; +//! - tell a file's name from its extension (everything after the last dot). +//! +//! **Introduces:** `pwd`, `ls`, `ls -l`, `--help`, `man`, `cd` (`..`, `/`, `~`, bare), +//! `echo $HOME`, Tab completion, command history, `shellgame reset` (in the maze).\ +//! **Relies on:** [welcome](super::s00_welcome) (`shellgame submit`, `shellgame hint`).\ +//! **Leaves for later:** file contents (`cat`) and `cd -` ([files](super::s02_files)), hidden +//! entries ([hidden](super::s03_hidden)), creating and deleting ([create](super::s04_create)), +//! wildcards ([wildcards](super::s07_wildcards)). + +use std::fs; +use std::io; +use std::path::Path; + +use crate::game::*; + +#[rustfmt::skip] +pub const SECTION: Section = Section { + title: "Navigace", + intro: r#" + Vítejte v první sekci ShellGame! Zde se naučíte základní dovednosti pro pohyb v příkazové řádce. Prvním krokem k ovládnutí příkazové řádky je pochopení, jak funguje souborový systém vašeho počítače. + + ### Stromová struktura + + Souborový systém vašeho počítače je organizován jako stromová struktura adresářů (složek) a souborů. Strom začíná v kořenovém adresáři (root, označeném `/`). Každý adresář může obsahovat další podadresáře, což vytváří hierarchii. + + ``` + / ← Kořen (root) - začátek všeho + ├── home/ ← Domovské adresáře uživatelů + │ └── student/ ← VÁŠ domovský adresář (~) + │ ├── dokumenty/ + │ ├── stažené/ + │ └── projekty/ + ├── tmp/ ← Dočasné soubory + │ └── shellgame-<user>/ ← Váš pracovní prostor pro hru + │ └── level-1/ ← ZDE ZAČÍNÁTE! + ├── etc/ ← Systémové konfigurace + └── usr/ ← Programy a knihovny + ``` + + ### Pracovní adresář (working directory) + + V každé chvíli se v terminálu nacházíte v určitém adresáři, který se nazývá "pracovní adresář" (working directory). Je to stejné, jako když máte v grafickém rozhraní otevřenou složku a vidíte její obsah. V terminálu však musíte vědět, kde jste, abyste mohli správně zadávat příkazy. + + Představte si strom z obrázku výše a na něm šipku, která vždy ukazuje na váš aktuální pracovní adresář. Pokaždé, když se příkazem `cd` přesunete jinam, šipka se posune s vámi. + + ### Cesta (Path) + + Cesta (path) je textový řetězec popisující umístění souboru nebo adresáře v systému. Cesta se skládá z jednotlivých adresářů oddělených lomítky (např. `/home/student/dokumenty`). + + --- + + ### Klíčové koncepty + + V této sekci se zaměříme na tři klíčové dovednosti: + 1. **Orientace**: Jak zjistit, kde se právě nacházíte. V grafickém rozhraní to vidíte v záhlaví okna, v terminálu se musíte zeptat. + 2. **Průzkum**: Jak zjistit, co se nachází ve vašem okolí. Naučíte se vypisovat obsah adresářů a rozpoznávat různé typy položek. + 3. **Pohyb**: Jak se přesouvat mezi adresáři. Naučíte se chodit "dovnitř" do podadresářů, vracet se "zpět" a skákat na konkrétní místa. + + ### Příkazy, které si osvojíte + + - `pwd` (Print Working Directory) - „Kde jsem?“ + - `ls` (List) - „Co tu je?“ + - `<příkaz> --help` - stručná nápověda k příkazu + - `man <příkaz>` - podrobný manuál + - `cd` (Change Directory) - „Jdi tam!“ + + ### Dva triky pro pohodlné psaní + - **Klávesa Tab (doplňování)**: Napište začátek jména a stiskněte Tab. Shell zbytek doplní sám, což vás uchrání od překlepů. + - **Šipky nahoru/dolů (historie)**: Stiskem šipky ↑ vyvoláte předchozí příkaz, aniž byste ho museli psát znovu. + + Jste připraveni začít svou cestu? + "#, + levels: &[ + Level { + title: "Aktuální umístění", + text: r#" + ### Cíl + Zjistěte název aktuálního adresáře. + + ### Příkazy + - `pwd` - vypíše celou cestu k aktuálnímu adresáři + + ### Úkol + 1. Spusťte `pwd` + 2. Odevzdejte název posledního adresáře v cestě (basename) + + ### Příklad + Cesta: `/home/student/dokumenty` -> Odpověď: `dokumenty` + + ### Odevzdání + `shellgame submit <název>` + "#, + hints: &[ + "Příkaz 'pwd' (Print Working Directory) vám ukáže, kde jste. Zkuste ho!", + "Podívejte se na výstup 'pwd'. Co je za posledním lomítkem?", + "Basename je poslední část cesty. Z cesty '/home/student/dokumenty' byste odevzdali 'dokumenty'.", + ], + answer: exact("level-1"), + check: &[ + pwd_used("Nejdřív prosím použijte 'pwd' (ShellGame sleduje použití příkazu) a pak odpověď odevzdejte znovu."), + ], + solution: r#" + pwd + shellgame submit "$(basename "$PWD")" + "#, + success: "Správně! 'pwd' vypisuje celou cestu; poslední část za lomítkem je adresář, ve kterém stojíte.", + ..LEVEL + }, + Level { + title: "Soubory, nebo adresáře?", + text: r#" + ### Cíl + Nejprve si prohlédněte běžný výpis. Potom v nápovědě zjistěte, jak zobrazit + podrobnosti, a rozlište soubor od adresáře bez spoléhání na barvy terminálu. + + ### Příkazy + - `ls` - vypíše názvy položek v aktuálním adresáři + - `ls --help` - zobrazí stručnou nápovědu k příkazu `ls` + - `man ls` - otevře podrobný manuál; ukončíte ho klávesou `q` + + ### Úkol + 1. Spusťte nejprve obyčejné `ls` + 2. V nápovědě najděte přepínač pro dlouhý (podrobný) výpis a použijte ho + 3. V podrobném výpisu značí první znak `d` adresář a `-` běžný soubor + 4. Najděte **adresář**, jehož název začíná na `d` a končí na `a` + 5. Odevzdejte jeho název + + ### Odevzdání + `shellgame submit <hodnota>` + "#, + hints: &[ + "Běžný výpis ukazuje hlavně názvy. Typ položky na něm poznáte jen někdy (podle barvy nebo lomítka za názvem, to záleží na shellu); spolehlivě ho ukáže podrobný výpis.", + "Zjistěte v nápovědě k příkazu 'ls', který přepínač zapíná dlouhý (podrobný) výpis.", + "Otevřete nápovědu pomocí 'ls --help' nebo 'man ls' a hledejte popis 'use a long listing format' (v české verzi 'vypíše výstup ve dlouhém formátu').", + "Spusťte 'ls -l': řádek adresáře začíná 'd', řádek běžného souboru '-'.", + ], + setup: &[ + clean("data"), clean("dog"), clean("dome"), + dir("alpha"), dir("delta"), dir("dome"), dir("gamma"), + file("data", ""), file("dog", ""), + ], + answer: exact("delta") + .mistakes(&[ + ("data", "'data' odpovídá názvem, ale není to adresář: jeho řádek v 'ls -l' začíná '-'."), + ("dome", "'dome' je adresář, ale jeho název nekončí na 'a'."), + ("dog", "'dog' začíná na 'd', ale nekončí na 'a'. Hledáme vzor d...a."), + ("alpha", "'alpha' končí na 'a', ale nezačíná na 'd'. Hledáme vzor d...a."), + ("gamma", "'gamma' končí na 'a', ale nezačíná na 'd'. Hledáme vzor d...a."), + ("Delta", "Shell rozlišuje velká a malá písmena. Odevzdejte název přesně tak, jak ho vypisuje 'ls'."), + ]), + solution: r#"shellgame submit "$(ls -l | grep '^d' | grep -o '[^ ]*$' | grep '^d.*a$')""#, + success: "Správně! Podle prvního znaku výpisu 'ls -l' jste rozlišili adresář od souboru.", + ..LEVEL + }, + Level { + title: "Název bez přípony", + text: r#" + ### Cíl + Zjistěte název souboru bez přípony. + + ### Příkazy + - `cd <název>` - změnit adresář + + ### Úkol + 1. Jděte do `alpha` (`cd alpha`) + 2. Najděte soubor uvnitř (`ls`) + 3. Odevzdejte název souboru **BEZ** přípony (vynechte poslední tečku a část za ní) + "#, + hints: &[ + "Použijte 'cd alpha' pro vstup do adresáře alpha.", + "Vypište obsah pomocí 'ls'. Uvidíte soubor s příponou '.txt'.", + "Odevzdejte název tohoto souboru, ale vynechejte část '.txt'.", + "Příklad: Pokud je soubor 'data.csv', odevzdejte 'data'.", + ], + setup: &[file("alpha/inside.txt", "first step")], + answer: exact("inside") + .mistakes(&[ + ("alpha", "'alpha' je adresář, ve kterém stojíte. Odevzdejte název souboru, který v něm vypíše 'ls', bez přípony."), + ("inside.txt", "Odevzdejte název souboru bez přípony: vynechte poslední tečku a část za ní."), + ("txt", "To je právě přípona. Odevzdejte tu část názvu, která je před poslední tečkou."), + (".txt", "To je právě přípona. Odevzdejte tu část názvu, která je před poslední tečkou."), + ("data", "'data' byl jen příklad z nápovědy. Odevzdejte název souboru, který leží v adresáři alpha."), + ]), + check: &[at("alpha")], + solution: r#" + cd alpha + shellgame submit "$(ls | sed 's/\.[^.]*$//')" + "#, + success: "Správně! Přípona je jen část názvu za poslední tečkou; to, co zbude, je název souboru bez přípony.", + ..LEVEL + }, + Level { + title: "Návrat na základnu", + text: r#" + ### Cíl + Vraťte se o úroveň výše. + + ### Příkazy + - `cd ..` - jít o úroveň výše + + ### Úkol + 1. Jděte do nadřazeného adresáře (`cd ..`) + 2. Odevzdejte název tohoto adresáře + "#, + hints: &[ + "Dvě tečky '..' označují nadřazený (rodičovský) adresář.", + "Použijte příkaz 'cd ..' pro přesun o jednu úroveň výše.", + "Spusťte 'pwd' a odevzdejte jen název posledního adresáře (část za posledním '/').", + ], + start: "alpha", + setup: &[file("alpha/inside.txt", "first step")], + answer: exact("level-1"), + check: &[at(""), cd_done("Použijte pro návrat přesně příkaz 'cd ..'.")], + cd: &[cd::exact("..", "Použijte přesně příkaz 'cd ..'.")], + solution: r#" + cd .. + shellgame submit "$(basename "$PWD")" + "#, + success: "Správně! '..' neodkazuje na konkrétní jméno, ale vždy na rodiče toho adresáře, kde právě jste.", + }, + Level { + title: "Hluboký ponor", + text: r#" + ### Cíl + Sestupte hluboko do adresářové struktury. + + ### 💡 Tip: Klávesa Tab (doplňování názvů) + Dlouhé cesty nemusíte vypisovat celé písmeno po písmenu! + Napište `cd g` a stiskněte **Tab** — shell název `gamma/` doplní za vás. + Pak napište `d` a znovu stiskněte **Tab**. Klávesa Tab je v terminálu váš největší pomocník proti překlepům. + + ### Úkol + 1. Začínáte v adresáři `level-1` + 2. Jděte do `gamma/deep/a/b/c/` (v adresáři `level-1`) — vyzkoušejte klávesu Tab! + 3. V cíli spusťte pouze `shellgame submit` + "#, + hints: &[ + "Použijte 'cd' pro vstup do adresářů. Vyzkoušejte klávesu Tab pro automatické doplňování!", + "Můžete jít postupně: cd gamma, cd deep, cd a...", + "Nebo najednou: cd gamma/deep/a/b/c (napište 'cd g' a stiskněte Tab)", + "Po přesunu ověřte polohu příkazem 'pwd' a spusťte jen 'shellgame submit'.", + ], + setup: &[dir("gamma/deep/a/b/c")], + check: &[at("gamma/deep/a/b/c")], + solution: r#" + cd gamma/deep/a/b/c + shellgame submit + "#, + success: "Správně! Lomítka spojují kroky do jedné relativní cesty — jeden 'cd' zvládne celou větev.", + ..LEVEL + }, + Level { + title: "Víceúrovňový výstup", + text: r#" + ### Cíl + Vystoupejte o více úrovní najednou. + + ### Příkazy + - `cd ../../..` - jít o 3 úrovně výše + + ### Úkol + 1. ShellGame vás na začátku umístí do správného adresáře (nemusíte řešit, kde jste skončili minule). + 2. Vraťte se o 3 úrovně výše **JEDNÍM** příkazem + 3. V cíli spusťte pouze `shellgame submit` + "#, + hints: &[ + "Cesty lze řetězit: každé '..' představuje posun o jednu úroveň nahoru.", + "Pro posun o tři úrovně najednou spojte tři segmenty: 'cd ../../..'.", + "Polohu si ověřte příkazem 'pwd'. Pokud jste správně, spusťte jen 'shellgame submit'.", + ], + start: "gamma/deep/a/b/c", + setup: &[dir("gamma/deep/a/b/c")], + check: &[at("gamma/deep"), cd_done("Vraťte se o tři úrovně jedním příkazem 'cd ../../..'.")], + cd: &[cd::exact("../../..", "Použijte jeden příkaz 'cd ../../..'.")], + solution: r#" + cd ../../.. + shellgame submit + "#, + success: "Správně! Úspěšně jste vystoupali o 3 úrovně.", + ..LEVEL + }, + Level { + title: "Navigace v bludišti", + text: r#" + ### Cíl + Projděte bludištěm podle instrukcí. + + ### Pravidla + - Start: `level-1/maze/entry/` + - Instrukce je v **názvu** souboru `GO_…` (příkaz `ls`) + - `GO_TO_DIR_x` → `cd x` + - `GO_UP_N` → jděte pomocí `cd ..` o N úrovní výše + - `GO_UP_N_THEN_GO_TO_x` → jděte o N úrovní výše a potom do `x` + - Během bludiště nemůžete odejít mimo `maze/` + + ### Úkol + 1. Ze startu sledujte názvy souborů `GO_…` + 2. Bludiště je záměrně delší: procvičíte opakované `ls`, sestup, návraty i přechod k sourozencům + 3. Jakmile vstoupíte do skutečného cíle, dokončete level příkazem `shellgame submit` + + Pokud se v bludišti ztratíte, příkaz `shellgame reset` vás vrátí zpět na začátek (`maze/entry`). + "#, + hints: &[ + "Sledujte pouze názvy souborů začínající na 'GO_'. Vypište je pomocí 'ls'.", + "U instrukce 'GO_UP_2_THEN_GO_TO_catacombs' se vraťte o dvě úrovně a vstupte do catacombs.", + "Spojený návrat lze zapsat jako 'cd ../..'; potom pokračujte příkazem 'cd catacombs'.", + "Vstupujte jen do adresářů, kam vás pošle soubor 'GO_…'; ostatní adresáře jsou odbočky nebo pasti.", + "Pokud se ztratíte, příkaz 'shellgame reset' vás vrátí na start.", + ], + start: "maze/entry", + setup: &[ + clean("maze"), + build(|root| build_maze(root, r#" + entry GO_TO_DIR_hall | courtyard dungeon + hall GO_TO_DIR_nexus | alcove + side_door GO_UP_1 + nexus GO_TO_DIR_passages | sanctum archives + rotunda GO_UP_1 + passages GO_TO_DIR_tunnel | gallery + shaft GO_UP_1 + tunnel GO_TO_DIR_cavern | crevice + cavern GO_TO_DIR_depths | grotto abyss + depths GO_UP_2_THEN_GO_TO_catacombs echoes.txt + catacombs GO_TO_DIR_vault | ossuary + crypts GO_UP_1 + vault GO_TO_DIR_chamber | iron_cell sepulcher + chamber GO_UP_2_THEN_GO_TO_labyrinth relic.txt + labyrinth GO_TO_DIR_corridor | dead_end ruins + corridor GO_TO_DIR_shrine | false_exit + mist GO_UP_1 + shrine GO_TO_DIR_final | mirage shadow + final GO_TO_DIR_final_v2 draft.txt | decoy_exit + final_v2 GO_TO_DIR_final_final old.bak | abandoned_branch + final_final GO_TO_DIR_opravdu_final_v2_FINAL | fake_end + opravdu_final_v2_FINAL VICTORY.marker + "#)), + ], + check: &[at("maze/entry/hall/nexus/passages/tunnel/catacombs/labyrinth/corridor/shrine/final/final_v2/final_final/opravdu_final_v2_FINAL")], + cd: &[ + cd::within("maze", "Nemůžete opustit bludiště před jeho dokončením."), + cd::arrive("maze/entry/hall/nexus/passages/tunnel/catacombs/labyrinth/corridor/shrine/final/final_v2/final_final/opravdu_final_v2_FINAL"), + ], + solution: r#" + cd hall + cd nexus + cd passages + cd tunnel + cd cavern + cd depths + cd ../../catacombs + cd vault + cd chamber + cd ../../labyrinth + cd corridor + cd shrine + cd final + cd final_v2 + cd final_final + cd opravdu_final_v2_FINAL + shellgame submit + "#, + success: "Správně! Prošli jste bludištěm až do skutečného cíle!", + ..LEVEL + }, + Level { + title: "Skok absolutní cestou", + text: r#" + ### Cíl + Použijte absolutní cestu. + + ### Příkazy + - `cd /cesta` - absolutní cesta (od kořene) + + ### Úkol + 1. Začínáte v `level-1`. Zjistěte celou cestu příkazem `pwd` + 2. Použijte **JEDEN** příkaz `cd` s absolutní cestou do podadresáře `absolute-target` + 3. V cíli spusťte pouze `shellgame submit` + "#, + hints: &[ + "Absolutní cesty začínají na /. Použijte 'pwd' pro zobrazení vaší plné cesty.", + "K celé cestě vypsané příkazem 'pwd' na startu připojte '/absolute-target'.", + "Po přesunu ověřte polohu příkazem 'pwd' a spusťte jen 'shellgame submit'.", + "Za 'cd' napište celou sestavenou cestu od /. Pokud obsahuje mezery, uzavřete ji do uvozovek.", + ], + setup: &[file("absolute-target/PLACEHOLDER.answer", "")], + check: &[ + at("absolute-target"), + cd_done("Jste ve správném adresáři, ale zatím to nevypadá, že jste použili absolutní 'cd' (začíná na /). Zkuste to znovu: opusťte adresář a vraťte se absolutní cestou."), + ], + cd: &[ + cd::absolute("Musíte použít absolutní cestu (začínající na /)."), + cd::arrive("absolute-target"), + ], + solution: r#" + cd "$PWD/absolute-target" + shellgame submit + "#, + success: "Správně! Dostali jste se sem absolutní cestou.", + ..LEVEL + }, + Level { + title: "Cesta z kořene domů", + text: r#" + ### Cíl + Zrekonstruujte cestu domů. + + ### Příkazy + - `cd /` - jít do kořene + - `echo $HOME` - zobrazit cestu domů + + ### Úkol + 1. Začínáte v `level-1`. Jděte do kořene (`cd /`) + 2. Zjistěte cestu domů (`echo $HOME`) + 3. Jděte domů krok za krokem (každý segment zvlášť) + 4. Nakonec použijte jen `shellgame submit` + + Poznámka: Váš domovský adresář (`$HOME`) leží mimo herní pracovní prostor. + "#, + hints: &[ + "Zjistěte cestu k domovu pomocí 'echo $HOME'.", + "Začněte v / a vstupujte do každého adresáře v cestě jeden po druhém (bez přeskakování).", + "Tip: pokud je HOME třeba /home/ada, udělejte: cd / ; cd home ; cd ada.", + "V tomhle levelu neodevzdáváte textovou odpověď – důležitá je správná sekvence 'cd'.", + ], + check: &[ + at_home(), + cd_done("Vypadá to, že jste se domů dostali, ale ShellGame nezaznamenal povinnou sekvenci 'cd' krok za krokem. Začněte znovu: 'cd /' a projděte každý segment cesty do $HOME po jednom."), + ], + cd: &[cd::custom(walk_rule, walk_track)], + solution: r#" + cd / + eval "$(echo "$HOME" | tr / '\n' | grep . | sed "s/.*/cd -- '&'/")" + shellgame submit + "#, + success: "Správně! Došli jste domů krok za krokem.", + ..LEVEL + }, + Level { + title: "Zkratka pro domov (~)", + text: r#" + ### Cíl + Naučte se používat zkratku `~` (vlnovku) pro rychlý návrat do domovského adresáře. + + ### Zkratka pro domov: `~` (vlnovka / tilda) + V předchozím levelu jste šli do domovského adresáře krok za krokem. V běžné práci + však celou cestu psát nemusíte: + - Znak `~` (vlnovka) je v shellu zkratka pro váš domovský adresář (`$HOME`). + - Příkaz `cd ~` vás okamžitě přenese domů, ať se nacházíte kdekoliv v systému. + - Zkratku lze použít i v cestách, např. `cd ~/dokumenty`. + - *(Tip: do domovského adresáře vás přenese i samotný příkaz `cd` bez parametrů.)* + + ### 💡 Jak napsat znak `~` na klávesnici + - **Česká klávesnice v Linuxu**: `Pravý Alt` (AltGr) + `A`. + - **Česká klávesnice ve Windows** (třeba v terminálu WSL nebo při připojení přes SSH): + `Pravý Alt` (AltGr) + klávesa `+` (vlevo nahoře, hned vedle `;`), potom stiskněte mezerník. + - **Anglická klávesnice**: `Shift` + klávesa pod `Esc` (vlevo nahoře). + + ### Úkol + 1. Začínáte v herním prostoru (`level-1`) + 2. Přejděte do domovského adresáře pomocí zkratky: `cd ~` + 3. Příkazem `pwd` ověřte, kde se nacházíte + 4. V cíli spusťte pouze `shellgame submit` + "#, + hints: &[ + "Znak '~' (vlnovka) je zkratka shellu pro domovský adresář ($HOME). Příkaz 'cd ~' vás přenese domů odkudkoliv.", + "Na české klávesnici napíšete '~' v Linuxu pomocí AltGr + A, ve Windows pomocí AltGr + '+' a potom mezerníku.", + "Po přesunu ověřte polohu příkazem 'pwd' a spusťte jen 'shellgame submit'.", + ], + check: &[ + at_home(), + pwd_used("Jste doma, ale ještě ověřte polohu příkazem 'pwd' a potom odevzdejte znovu."), + ], + solution: r#" + cd ~ + pwd + shellgame submit + "#, + success: "Správně! Shell nahradí '~' na začátku cesty vaším domovským adresářem, ať stojíte kdekoliv. Pozor: uvnitř uvozovek se '~' nerozbalí.", + ..LEVEL + }, + Level { + title: "Souhrn", + text: r#" + ### Výzva: Otestujte své dovednosti! + + Ukažte, co jste se naučili v této sekci. Proveďte následující kroky: + + ### Úkol + 1. Začínáte v `level-1`. Zjistěte svou aktuální polohu (`pwd`) + 2. Přejděte do adresáře `gamma/deep/a/b/c` + 3. Vraťte se o 4 úrovně výše jedním příkazem + 4. V cíli spusťte pouze `shellgame submit` + + ### Shrnutí příkazů + ``` + pwd → Kde jsem? + ls → Co tu je? + cd adresář → Vstup do adresáře + cd .. → O úroveň výše + cd ../.. → O více úrovní výše + cd /cesta → Absolutní cesta + cd ~ → Domů + ``` + + ### Odevzdání + `shellgame submit` + "#, + hints: &[ + "Nejdřív si pomocí 'pwd' potvrďte výchozí polohu a cestu rozdělte na jednotlivé adresáře.", + "Při návratu spočítejte, kolik segmentů musíte z adresáře 'c' odstranit. Každý segment '..' znamená jednu úroveň.", + "Použijte 'cd gamma/deep/a/b/c' a potom 'cd ../../../..'. Výsledek ověřte pomocí 'pwd'.", + ], + setup: &[dir("gamma/deep/a/b/c")], + check: &[ + at("gamma"), + pwd_used("Nejdřív použijte 'pwd'."), + cd_done("Přejděte do 'c' a vraťte se jedním příkazem o čtyři úrovně."), + ], + cd: &[ + cd::when_in("gamma/deep/a/b/c"), + cd::exact("../../../..", "Z adresáře 'c' se vraťte o čtyři úrovně jediným příkazem 'cd': každá úroveň je jeden segment '..' a segmenty se spojují lomítkem."), + ], + solution: r#" + pwd + cd gamma/deep/a/b/c + cd ../../../.. + shellgame submit + "#, + success: "Výborně! Ovládáte základy navigace!", + ..LEVEL + }, + ], +}; + +const TRAP: &str = "YOU_ARE_NOT_SUPPOSED_TO_BE_HERE"; +const VICTORY: &str = "VICTORY.marker"; + +/// A short note so `cat` is not a blank page, as text for `file_text` (the trap note is a block); an +/// instruction file keeps its name as the instruction. +fn note(name: &str) -> &'static str { + if name.starts_with("GO_") { + "Instrukce je v názvu tohoto souboru. Řiďte se jménem, ne obsahem.\n" + } else if name == TRAP { + r#" + Tady nemáte být. Vraťte se a sledujte soubory začínající na GO_. + Ztratili jste se? Příkaz 'shellgame reset' vás vrátí na začátek bludiště (maze/entry). + "# + } else if name == VICTORY { + "Konečně skutečný cíl (opravdu_final_v2_FINAL)! Level dokončíte příkazem: shellgame submit\n" + } else { + "" + } +} + +/// Builds the maze of "Navigace v bludišti" below `maze/` from its `plan`, an indented block with +/// one line per room nested by indentation (two spaces per level): `room file... | dead end...`. A +/// dead end is a room holding only the trap note. +fn build_maze(root: &Path, plan: &str) -> io::Result<()> { + let mut rooms = vec!["maze".to_owned()]; + for line in dedent(plan).lines() { + rooms.truncate((line.len() - line.trim_start().len()) / 2 + 1); + let (room, dead_ends) = line.trim().split_once(" | ").unwrap_or((line.trim(), "")); + let mut words = room.split(' '); + rooms.push(words.next().unwrap_or_default().to_owned()); + let dir = rooms.join("/"); + mkdir(root, &dir)?; + for file in words { + write(root, &format!("{dir}/{file}"), file_text(note(file)))?; + } + for dead_end in dead_ends.split_whitespace() { + mkdir(root, &format!("{dir}/{dead_end}"))?; + write(root, &format!("{dir}/{dead_end}/{TRAP}"), file_text(note(TRAP)))?; + } + } + Ok(()) +} + +/// "Cesta z kořene domů" walks from `/` to `$HOME` one directory at a time. A `cd` that would skip +/// steps is blocked: a bare `cd`, any absolute path but `/` (`cd ~` and `cd $HOME` arrive expanded) +/// and any path with more than one segment. +fn walk_rule(mv: &CdMove) -> Result<(), String> { + if mv.is_bare() { + Err("Skoky nejsou povoleny. Jděte krok za krokem.".to_owned()) + } else if mv.target == "/" { + Ok(()) + } else if mv.target.starts_with('/') { + Err("Absolutní skoky nejsou povoleny (kromě cd /).".to_owned()) + } else if mv.target.trim_end_matches('/').contains('/') { + Err("Cestujte po jednom segmentu (adresáři).".to_owned()) + } else { + Ok(()) + } +} + +/// Tracks the walk after each successful move in `.cd_walk`, the directories visited since `/`, one +/// per line; reaching `$HOME` at the end of an unbroken walk records the evidence and stepping aside +/// or up restarts it. Everything compares the logical directories the shell reports, never +/// canonical paths: a symlink to `$HOME` is no shortcut. +fn walk_track(mv: &CdMove) { + let (ctx, Some(dest)) = (mv.ctx, mv.dest) else { return }; + if dest == Path::new("/") { + let _ = ctx.mark(".cd_walk", "/"); + } else if let Ok(walked) = fs::read_to_string(ctx.marker(".cd_walk")) { + let last = Path::new(walked.lines().last().unwrap_or_default()); + if dest.parent() == Some(last) { + let _ = ctx.mark(".cd_walk", &format!("{walked}\n{}", dest.display())); + if dest == ctx.home { + let _ = ctx.mark(CD_DONE, ""); + } + } else if dest != last { + let _ = fs::remove_file(ctx.marker(".cd_walk")); + } + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + use crate::game::testing::World; + + /// The instruction files of a room, and where each sends the player. + fn instructions(room: &Path) -> Vec<(String, PathBuf)> { + let names = fs::read_dir(room) + .unwrap() + .flatten() + .map(|e| e.file_name().to_string_lossy().into_owned()); + let go = |name: &String| { + if let Some(dir) = name.strip_prefix("GO_TO_DIR_") { + return room.join(dir); + } + let rest = name.strip_prefix("GO_UP_").unwrap(); + let (up, then) = rest.split_once("_THEN_GO_TO_").unwrap_or((rest, "")); + room.ancestors().nth(up.parse().unwrap()).unwrap().join(then) + }; + names + .filter(|name| name.starts_with("GO_")) + .map(|name| (go(&name), name)) + .map(|(to, name)| (name, to)) + .collect() + } + + #[test] + fn every_instruction_leads_to_a_room_and_the_main_route_reaches_the_goal() { + let pos = Pos::all().find(|pos| pos.level().start == "maze/entry").unwrap(); + let world = World::new(); + let maze = world.start(pos).section_root.join("maze"); + let (mut rooms, mut followed, mut goal) = (vec![maze.clone()], 0, None); + while let Some(room) = rooms.pop() { + rooms.extend( + fs::read_dir(&room) + .unwrap() + .flatten() + .map(|e| e.path()) + .filter(|path| path.is_dir()), + ); + for (name, to) in instructions(&room) { + assert!(to.is_dir() && to.starts_with(&maze), "{name} in {room:?}"); + followed += 1; + } + if room.join(VICTORY).is_file() { + goal = Some(room); + } + } + assert!(followed > 10); + let (goal, mut room) = (goal.expect("a room holds the victory marker"), maze.join("entry")); + for _ in 0..40 { + if room == goal { + return; + } + let go = instructions(&room); + assert_eq!(go.len(), 1, "one instruction in {room:?}"); + room = go[0].1.clone(); + } + panic!("the main route does not reach the goal"); + } +} diff --git a/src/levels/s02_files.rs b/src/levels/s02_files.rs new file mode 100644 index 0000000..4ca4ccb --- /dev/null +++ b/src/levels/s02_files.rs @@ -0,0 +1,373 @@ +//! Working with files: moving between directories with purpose, and reading and creating files. +//! +//! **Purpose.** Real work means jumping between places and reading files: shortcuts for moving, a +//! first look inside files (`cat`), a first created file (`touch`), and looking commands up. +//! +//! **Learning objectives.** After this section a student can: +//! - reach a sibling or another branch with one relative path (`cd ../finish`), return with `cd -`; +//! - read a file with `cat`, report a word from it, follow a trail of clues to a password, and stop +//! a command that waits for input with Ctrl+C; +//! - create an empty file with `touch` and check it with `ls`; +//! - search `--help` or `man` (`/`, `n`, `q`) and match a short option to its long name (`-h`). +//! +//! **Introduces:** `cd -`, `cat`, `touch`, Ctrl+C, searching in `--help` and `man`.\ +//! **Relies on:** [navigation](super::s01_navigation) (`pwd`, `ls`, `cd`, `..`, relative paths).\ +//! **Leaves for later:** hidden files ([hidden](super::s03_hidden)), `mkdir` and deleting +//! ([create](super::s04_create)), copying and moving ([copy_move](super::s06_copy_move)), wildcards +//! ([wildcards](super::s07_wildcards)), writing into files ([redirection](super::s09_redirection)). + +use crate::game::*; + +#[rustfmt::skip] +pub const SECTION: Section = Section { + title: "Práce se soubory", + intro: r#" + V této sekci se naučíte pracovat se soubory a pokročilejší navigaci. + + ### Proč je to důležité? + Skutečná práce v terminálu je stálé přebíhání mezi několika místy: + zdrojový kód a testy, konfigurace a log. Kdo umí jen `cd jmeno`, + vypisuje pokaždé celou cestu znovu. Po této sekci se mezi dvěma místy + přepnete jedním krátkým příkazem — a obsah souboru si zobrazíte, + aniž byste otevírali editor. + + ### Navigace mezi sourozeneckými adresáři + ``` + level-2/ + ├── start/ ← Jste tady + ├── finish/ ← Chcete se sem dostat + └── other/ + + Jak se dostat ze 'start' do 'finish'? + 1. Krok zpět: cd .. (jste v level-2) + 2. Krok dovnitř: cd finish (jste ve finish) + + Nebo jedním příkazem: cd ../finish + ``` + + ### Co se naučíte + - Navigace mezi sousedními adresáři (`cd ../jiný`) + - Rychlý návrat do předchozího adresáře (`cd -`) + - Čtení obsahu souborů (`cat`) + - Přerušení běžícího příkazu klávesovou zkratkou (**Ctrl+C**) + - Vytváření souborů (`touch`) + + ### Proč je `cd -` užitečný? + Když často přepínáte mezi dvěma místy (např. zdrojový kód a testy), + `cd -` vás vrátí tam, kde jste byli naposledy - bez psaní celé cesty. + + ### Pokračování + Pro zahájení prvního levelu této sekce stiskněte Enter. + "#, + levels: &[ + Level { + title: "Navigace mezi sourozenci", + text: r#" + ### Cíl + Přejděte z aktuálního podadresáře do sousedního (sourozeneckého) adresáře. + + ### Struktura adresářů + ``` + level-2/ + ├── start/ ← Zde se nacházíte (start) + └── finish/ ← Váš cíl + ``` + + ### Navigace mezi sourozenci + Adresáře `start` a `finish` leží vedle sebe ve stejném rodičovském adresáři (`level-2`). + Protože `finish` neleží uvnitř `start`, nelze použít přímý příkaz `cd finish`. + Musíte se nejprve vrátit k rodiči (`..`) a odtud vstoupit do cíle: + - Ve dvou krocích: nejprve o úroveň výše (`cd ..`) a potom do cíle. + - Nebo v jednom kroku spojenou relativní cestou: `cd ../<cílový_adresář>`. + + ### Úkol + 1. Začínáte v adresáři `level-2/start` + 2. Přejděte do sousedního adresáře `finish` + 3. V cíli ověřte polohu příkazem `pwd` a odešlete řešení + + ### Odevzdání + V cílovém adresáři spusťte: + `shellgame submit` + "#, + hints: &[ + "Do sourozeneckého adresáře se dostanete přes společného rodiče ('..').", + "Zkuste nejprve vystoupat o úroveň výše nebo použít spojenou relativní cestu začínající '../'.", + "Po přesunu ověřte polohu příkazem 'pwd' a spusťte 'shellgame submit'.", + ], + start: "start", + setup: &[dir("start"), dir("finish")], + check: &[at("finish")], + solution: r#" + cd ../finish + shellgame submit + "#, + success: "Správně! K sourozenci se chodí přes společného rodiče — nahoru a hned dolů jiným směrem.", + ..LEVEL + }, + Level { + title: "Rychlý návrat", + text: r#" + ### Cíl + Naučte se rychle přepínat mezi dvěma adresáři. + + ### Příkazy k naučení + - `cd -` (návrat do předchozího adresáře) + + ### Úkol + 1. Začínáte v `level-2/location-A`. + 2. Přejděte do `level-2/location-B` (použijte `cd ../location-B`). + 3. Použijte příkaz `cd -` pro okamžitý návrat zpět do `location-A`. + 4. Po návratu spusťte pouze `shellgame submit`. + "#, + hints: &[ + "Příkaz 'cd -' vás vrátí do předchozího pracovního adresáře (jako tlačítko Zpět).", + "Nejprve přejděte do 'location-B' ('cd ../location-B') a odtud zadejte 'cd -'.", + "Po návratu ověřte polohu příkazem 'pwd' a spusťte jen 'shellgame submit'.", + ], + start: "location-A", + setup: &[dir("location-A"), dir("location-B")], + check: &[at("location-A"), cd_done("Nejdřív přejděte do 'location-B' a vraťte se příkazem 'cd -'.")], + cd: &[ + cd::when_in("location-B"), + cd::exact("-", "Z adresáře location-B se vraťte příkazem 'cd -'."), + cd::arrive("location-A"), + ], + solution: r#" + cd ../location-B + cd - + shellgame submit + "#, + success: "Správně! Shell si pamatuje předchozí adresář, takže 'cd -' přepíná mezi dvěma místy bez cesty.", + ..LEVEL + }, + Level { + title: "Hluboká navigace", + text: r#" + ### Cíl + Navigace složitější strukturou pomocí relativních cest. + + ### Úkol + Nacházíte se hluboko ve struktuře adresářů. + Vaším cílem je přejít do jiné větve stromu pomocí jediného příkazu `cd`. + + - Start: `.../level-2/deep/structure/start` + - Cíl: `.../level-2/deep/other/target` + + Musíte jít o dvě úrovně výše a pak dolů do `other/target`. + + V cíli spusťte pouze `shellgame submit`. + + Potřebujete pomoc? Napište: `shellgame hint` + "#, + hints: &[ + "Pro přechod do jiné větve stromu musíte nejprve vystoupat nahoru přes '..' a pak sestoupit dolů.", + "Ze 'start' vystoupejte o dvě úrovně ('../..') a zadejte 'cd ../../other/target'.", + "Po přesunu ověřte polohu příkazem 'pwd' a spusťte jen 'shellgame submit'.", + ], + start: "deep/structure/start", + setup: &[dir("deep/structure/start"), dir("deep/other/target")], + check: &[ + at("deep/other/target"), + cd_done("Použijte ze startu jeden relativní příkaz 'cd ../../other/target'."), + ], + cd: &[cd::exact("../../other/target", "Použijte ze startu jeden příkaz 'cd ../../other/target'.")], + solution: r#" + cd ../../other/target + shellgame submit + "#, + success: "Správně! Jedna relativní cesta umí obsahovat výstup i sestup — nejdřív '..', potom jména větve.", + ..LEVEL + }, + Level { + title: "Čtení souboru", + text: r#" + ### Cíl + Přečtěte si obsah souboru. + + ### Příkazy k naučení + - `cat <soubor>` (vypíše obsah souboru do terminálu) + + ### ⚠️ Bezpečnostní pojistka: Ctrl+C + Kdybyste omylem spustili `cat` bez názvu souboru, příkaz neví, co číst, + a začne čekat na vstup z klávesnice (terminál se zdánlivě „zasekne“). + Kdykoliv se vám to stane, stiskněte **Ctrl+C** — to běžící příkaz okamžitě přeruší. + + ### Úkol + 1. V aktuálním adresáři je soubor `message.txt`. + 2. Přečtěte si jeho obsah pomocí `cat message.txt`. + 3. Odevzdejte **PRVNÍ SLOVO**, které v souboru najdete. + + Odevzdejte pomocí: `shellgame submit <slovo>` + + Potřebujete pomoc? Napište: `shellgame hint` + "#, + hints: &[ + "Příkaz 'cat' vypíše obsah textového souboru na obrazovku.", + "Spusťte 'cat message.txt' pro zobrazení obsahu souboru.", + "Z výstupu vezměte jen první slovo (před první mezerou) a zadejte: 'shellgame submit <slovo>'.", + ], + setup: &[file("message.txt", "Secret is the key.\n")], + answer: exact("secret").nocase(), + solution: r#"shellgame submit "$(cut -d' ' -f1 message.txt)""#, + success: "Správně! 'cat' vysype celý obsah souboru do terminálu — ideální na krátké textové soubory.", + ..LEVEL + }, + Level { + title: "Sledování stop", + text: r#" + ### Cíl + Sledujte stopy v souborech k nalezení hesla. + + ### Úkol + 1. Přečtěte si soubor `start.txt` v aktuálním adresáři. + 2. Postupujte podle instrukcí v souboru. + 3. Najděte finální heslo a odevzdejte ho. + + Odevzdejte pomocí: `shellgame submit <heslo>` + + Potřebujete pomoc? Napište: `shellgame hint` + "#, + hints: &[ + "Začněte čtením start.txt - co vám soubor říká?", + "Instrukce vás posílají někam dál. Jaký příkaz použijete pro přesun do adresáře?", + "Přečtěte start.txt, přejděte do adresáře 'next', přečtěte clue.txt.", + ], + setup: &[ + file("start.txt", "Jděte do adresáře 'next' a přečtěte si clue.txt\n"), + file("next/clue.txt", "Heslo je 'sunshine'\n"), + ], + answer: exact("sunshine").nocase(), + solution: r#" + cat start.txt + cd next + shellgame submit "$(cut -d"'" -f2 clue.txt)" + "#, + success: "Správně! Střídat 'ls', 'cd' a 'cat' stačí k prozkoumání libovolné neznámé struktury.", + ..LEVEL + }, + Level { + title: "Vytvoření souboru", + text: r#" + ### Cíl + Vytvořte nový prázdný soubor. + + ### Příkazy k naučení + - `touch <název>` (vytvoří prázdný soubor nebo aktualizuje časové značky existujícího souboru) + + ### Úkol + 1. Vytvořte soubor s názvem `my_file.txt` v aktuálním adresáři. + 2. Ověřte jeho existenci pomocí `ls`. + + Odevzdejte pomocí: `shellgame submit` + + Potřebujete pomoc? Napište: `shellgame hint` + "#, + hints: &[ + "Příkaz 'touch' vytvoří prázdný soubor se zadaným názvem.", + "Spusťte 'touch my_file.txt' v aktuálním adresáři.", + "Ověřte vytvoření souboru příkazem 'ls' a odešlete: 'shellgame submit'.", + ], + setup: &[clean("my_file.txt")], + check: &[is_file("my_file.txt")], + solution: r#" + touch my_file.txt + shellgame submit + "#, + success: "Správně! 'touch' založí prázdný soubor; u existujícího jen posune časové značky.", + ..LEVEL + }, + Level { + title: "Jak najít pomoc", + text: r#" + ### Cíl + Naučte se vyhledávat v nápovědě k příkazům. + + ### Dva způsoby, jak získat pomoc + Nikdo si nepamatuje všechny přepínače všech příkazů: + 1. `<příkaz> --help` → stručný přehled přepínačů přímo v terminálu + 2. `man <příkaz>` → podrobný manuál (stránkovaný; ukončíte ho klávesou `q`) + + ### 💡 Hledání v manuálu (`man`) + V manuálu můžete vyhledávat: + - Stiskněte klávesu `/`, napište hledaný text (např. `-h`) a stiskněte **Enter**. + - Klávesou `n` přejdete na další výskyt. + - Klávesou `q` manuál ukončíte. + + ### Úkol + 1. Spusťte `ls --help` nebo otevřete manuál `man ls`. + 2. Vyhledejte přepínač `-h`. + 3. Krátké přepínače mívají svůj dlouhý ekvivalent začínající na `--` (např. `-a` má `--all`). + Jaký dlouhý název má přepínač `-h`? + 4. Odevzdejte tento dlouhý název, s úvodními pomlčkami nebo bez nich + (pro přepínač `-a` byste odevzdali `all` nebo `--all`). + + ### Odevzdání + `shellgame submit <název>` + "#, + hints: &[ + "Spusťte 'ls --help' nebo 'man ls' a vyhledejte řádek s přepínačem '-h'.", + "V nápovědě uvidíte zápis ve tvaru '-h, --název'. Hledejte slovo za dvěma pomlčkami.", + "V 'man ls' napište '/-h,' a stiskněte Enter: manuál skočí na řádek, který začíná '-h,'. Za čárkou na něm stojí dlouhý název začínající '--'; odevzdejte celé slovo za pomlčkami, i se spojovníkem uprostřed.", + ], + answer: one_of(&[ + "human-readable", + "--human-readable", + "human readable", + "human", + "čitelné", + "citelne", + "čitelné formátování", + ]) + .nocase() + .error("Odpověď není správná. V nápovědě vyhledejte řádek s přepínačem '-h' a najděte jeho dlouhý název (--...)."), + solution: r#"shellgame submit "$(ls --help | grep -o -e '-h, --[a-z-]*' | cut -d' ' -f2)""#, + success: "Správně! Teď víte, jak najít pomoc: přepínač '--help' a příkaz 'man' jsou vaši nejlepší přátelé.", + ..LEVEL + }, + Level { + title: "Integrační výzva", + text: r#" + ### Výzva: Propojte navigaci a čtení souborů + + Závěrečný úkol sekce: použijete v něm několik dovedností najednou. + + ### Úkol + 1. Začínáte v `level-2`. Přejděte do `challenge/room1`. + 2. Přečtěte soubor `hint.txt`. + 3. Podle stopy přejděte do `room2`. + 4. Přečtěte `password.txt` a odevzdejte nalezené heslo. + + ### Odevzdání + `shellgame submit <hodnota>` + "#, + hints: &[ + "Ze startu přejděte do 'challenge/room1' a přečtěte 'hint.txt'.", + "Do sousedního 'room2' se dostanete například příkazem 'cd ../room2'.", + "V 'room2' přečtěte soubor 'password.txt' pomocí 'cat'.", + ], + setup: &[ + file("challenge/room1/hint.txt", "Heslo je v sousedním adresáři room2.\n"), + file("challenge/room2/password.txt", "navigator\n"), + file("challenge/room2/decoy.txt", "Toto není heslo.\n"), + ], + answer: exact("navigator") + .nocase() + .mistakes(&[ + ("toto není heslo.", "To je obsah decoy.txt, ne password.txt. Přečtěte správný soubor."), + ("toto není heslo", "To je obsah decoy.txt, ne password.txt. Přečtěte správný soubor."), + ("toto neni heslo.", "To je obsah decoy.txt, ne password.txt. Přečtěte správný soubor."), + ("toto neni heslo", "To je obsah decoy.txt, ne password.txt. Přečtěte správný soubor."), + ]) + .error("Heslo není správné. Hledejte password.txt v room2."), + check: &[at("challenge/room2")], + solution: r#" + cd challenge/room1 + cat hint.txt + cd ../room2 + shellgame submit "$(cat password.txt)" + "#, + success: "Výborně! Dokončili jste Sekci 2 a umíte propojit navigaci se čtením souborů.", + ..LEVEL + }, + ], +}; diff --git a/src/levels/s03_hidden.rs b/src/levels/s03_hidden.rs new file mode 100644 index 0000000..84f62f8 --- /dev/null +++ b/src/levels/s03_hidden.rs @@ -0,0 +1,256 @@ +//! Hidden files: names that start with a dot, `ls -a`, and working with them like any other file. +//! +//! **Purpose.** A plain `ls` leaves things out, the first real surprise of the shell. The section +//! shows that "hidden" is only a naming convention; `cat` and `cd` work as before. +//! +//! **Learning objectives.** After this section a student can: +//! - explain what makes an entry hidden (a leading dot) and reveal it with `ls -a`; +//! - tell hidden directories from hidden files in `ls -la` and count the directories, leaving out +//! the entries `.` and `..`; +//! - read a hidden file with `cat` and enter a hidden directory with `cd`; +//! - pick a hidden file by its suffix and give its whole name, leading dot included. +//! +//! **Introduces:** `ls -a`, `ls -la` (type column only), `.` as the current directory.\ +//! **Relies on:** [navigation](super::s01_navigation), [files](super::s02_files) (`cat`).\ +//! **Leaves for later:** creating and deleting ([create](super::s04_create)), size and permission +//! columns of `ls -l` ([inspect](super::s05_inspect)), wildcards +//! ([wildcards](super::s07_wildcards)). + +use crate::game::*; + +#[rustfmt::skip] +pub const SECTION: Section = Section { + title: "Skryté soubory", + intro: r#" + V této sekci odhalíte tajemství skrytých souborů a adresářů. + + ### Proč je to důležité? + „Chci upravit nastavení editoru, ale nemůžu ho nikde najít.“ + „Projekt má fungovat, ale chybí mu `.env`.“ + „Git se chová divně — kde vůbec ta konfigurace je?“ + + Všechny tři odpovědi leží v souborech, které `ls` záměrně neukáže. + Dokud o nich nevíte, vypadá adresář prázdně — i když prázdný není. + + ### Proč existují skryté soubory? + V Linuxu soubory začínající tečkou (`.`) jsou "skryté" - nezobrazí se při běžném `ls`. + Používají se pro konfigurační soubory, které nechcete mít na očích při běžné práci. + + ### Běžné skryté soubory + ``` + ~/ ← Váš domovský adresář + ├── dokumenty/ ← Běžný adresář (viditelný) + ├── .bashrc ← Konfigurace shellu (SKRYTÝ) + ├── .gitconfig ← Konfigurace Gitu (SKRYTÝ) + ├── .config/ ← Konfigurační adresář (SKRYTÝ) + │ └── nvim/ ← Nastavení editoru + └── .ssh/ ← SSH klíče (SKRYTÝ) + + Příkaz 'ls' zobrazí pouze: dokumenty/ + Příkaz 'ls -a' zobrazí VŠE včetně skrytých! + Příkaz 'ls -la' přidá podrobnosti; adresář poznáte podle 'd' na začátku řádku. + ``` + + ### Co se naučíte + - Co jsou skryté soubory (začínají tečkou) + - Jak je zobrazit a rozlišit jejich typ (`ls -a`, `ls -la`) + - Jak s nimi pracovat (čtení, navigace) + + ### Pokračování + Pro zahájení prvního levelu této sekce stiskněte Enter. + "#, + levels: &[ + Level { + title: "Počítání skrytých adresářů", + text: r#" + ### Cíl + Najděte a spočítejte skryté adresáře. + + ### Příkazy k naučení + - `ls -a` zobrazí všechny položky včetně skrytých + - `ls -la` navíc zobrazí podrobnosti a typ položky + + ### Úkol + Nacházíte se v adresáři `level-3/hub`. + 1. Použijte `ls -la` pro zobrazení všech položek i jejich typů. + 2. Spočítejte **skryté adresáře**: jejich název začíná tečkou a řádek znakem `d`. + 3. **Důležité:** Do počtu NEZAHRNUJTE speciální adresáře `.` (aktuální) a `..` (nadřazený). + + Odevzdejte počet nalezených skrytých adresářů (číslo). + + Odevzdejte pomocí: `shellgame submit <hodnota>` + + Potřebujete pomoc? Napište: `shellgame hint` + "#, + hints: &[ + "Použijte 'ls -la': -a zobrazí skryté položky a -l přidá podrobnosti.", + "Adresář poznáte podle znaku 'd' na začátku řádku; běžný soubor začíná '-'.", + "Počítejte skryté názvy v řádcích začínajících 'd', ale vynechte položky '.' a '..'.", + ], + start: "hub", + setup: &[ + clean("hub"), + dir("hub/.beta"), + dir("hub/.gamma"), + dir("hub/visible_dir"), + file("hub/.config", ""), + file("hub/visible_file.txt", ""), + ], + answer: int(2) + .mistakes(&[ + ("3", "Nejspíš počítáte i skrytý soubor '.config' (jeho řádek nezačíná 'd'), nebo viditelný adresář 'visible_dir' (jeho název nezačíná tečkou)."), + ("4", "Nejspíš počítáte i položky '.' a '..'. Jsou to speciální odkazy, ne hledané adresáře."), + ("5", "Nejspíš počítáte '.', '..' i skrytý soubor. Sledujte první znak řádku a přesný název."), + ]), + solution: r#"shellgame submit "$(ls -la | grep -c '^d.* \.[^.]')""#, + success: "Správně! Skryté položky zobrazí 'ls -a' a typ položky poznáte podle prvního znaku řádku 'ls -l'.", + ..LEVEL + }, + Level { + title: "Čtení skrytého souboru", + text: r#" + ### Cíl + Přečtěte obsah skrytého souboru. + + ### Úkol + V aktuálním adresáři je skrytý soubor `.secret_config`. + 1. Ověřte jeho existenci pomocí `ls -a`. + 2. Přečtěte jeho obsah pomocí `cat`. + 3. Odevzdejte obsah souboru. + + Odevzdejte pomocí: `shellgame submit <obsah>` + + Potřebujete pomoc? Napište: `shellgame hint` + "#, + hints: &[ + "Skryté soubory začínají tečkou. Jak je zobrazíte pomocí ls?", + "Příkaz 'cat' funguje i na skryté soubory - stačí zadat správný název včetně tečky.", + "Zkuste: cat .secret_config", + ], + setup: &[file(".secret_config", "mode=stealth\n")], + answer: exact("mode=stealth"), + solution: r#"shellgame submit "$(cat .secret_config)""#, + success: "Správně! Tečka na začátku názvu soubor jen skryje ve výpisu, práci s ním nijak neomezuje.", + ..LEVEL + }, + Level { + title: "Uvnitř skrytého adresáře", + text: r#" + ### Cíl + Vstupte do skrytého adresáře. + + ### Úkol + 1. Najděte skrytý adresář `.vault`. + 2. Vstupte do něj (`cd .vault`). + 3. Uvnitř najděte soubor `key.txt` a přečtěte ho. + 4. Odevzdejte nalezený klíč. + + Odevzdejte pomocí: `shellgame submit <klíč>` + + Potřebujete pomoc? Napište: `shellgame hint` + "#, + hints: &[ + "Jak byste vstoupili do běžného adresáře? Stejně to funguje i se skrytými.", + "Skrytý adresář .vault - jak se do něj dostanete pomocí cd?", + "Jděte do .vault pomocí 'cd .vault', pak přečtěte key.txt.", + ], + start: "hub", + setup: &[file("hub/.vault/key.txt", "platinum\n")], + answer: exact("platinum").nocase(), + check: &[at("hub/.vault")], + solution: r#" + cd .vault + shellgame submit "$(cat key.txt)" + "#, + success: "Správně! Do skrytého adresáře se vstupuje příkazem 'cd' úplně stejně jako do viditelného.", + ..LEVEL + }, + Level { + title: "Skrytá záloha", + text: r#" + ### Cíl + Identifikujte skrytý soubor podle přípony. + + ### Úkol + V adresáři `level-3/backup` je několik skrytých souborů. + Najděte ten, který má příponu `.bak` (záloha). + Odevzdejte jeho celý název. + + Odevzdejte pomocí: `shellgame submit <název-souboru>` + + Potřebujete pomoc? Napište: `shellgame hint` + "#, + hints: &[ + "Použijte 'ls -a' v adresáři backup.", + "Hledejte soubor začínající tečkou a končící .bak.", + "Odevzdejte celý název včetně tečky na začátku.", + ], + start: "backup", + setup: &[file("backup/.config", ""), file("backup/.data.bak", ""), file("backup/normal.txt", "")], + answer: suffix(".data.bak") + .mistakes(&[ + ("data.bak", "Skoro: úvodní tečka je součástí názvu, odevzdejte název celý i s ní."), + (".data", "Odevzdejte celý název souboru i s příponou."), + ]), + solution: r#"shellgame submit "$(ls -a | grep '\.bak$')""#, + success: "Správně! Úvodní tečka i přípona jsou součástí názvu, takže se soubor odevzdává celým jménem.", + ..LEVEL + }, + Level { + title: "Souhrn Sekce 3", + text: r#" + ### Výzva: Mistři skrytých souborů + + Ukažte, že ovládáte práci se skrytými soubory! + + ### Úkol + V adresáři `level-3/final_test` jsou normální i skryté položky. + + 1. Pomocí `ls -la` spočítejte **skryté adresáře** (název začíná tečkou, řádek znakem `d`; bez `.` a `..`) + 2. Najděte skrytý soubor `.secret_code` + 3. Přečtěte jeho obsah + 4. Odevzdejte dvojici ve schématu `POČET,KÓD` + + ### Shrnutí příkazů Sekce 3 + ``` + ls -a → Zobrazí vše včetně skrytých + ls -la → Přidá podrobnosti; adresář má na začátku řádku d + cat .soubor → Přečíst skrytý soubor + cd .adresar → Vstoupit do skrytého adresáře + ``` + + ### Odevzdání + `shellgame submit <hodnota>` + "#, + hints: &[ + "Skryté položky začínají tečkou. Použijte 'ls -la' pro zobrazení všech položek i jejich typů.", + "Adresáře poznáte podle 'd' na začátku řádku; běžné soubory začínají '-'.", + "Položky '.' a '..' nepočítejte. Obsah souboru zobrazíte pomocí 'cat .secret_code'.", + ], + start: "final_test", + setup: &[ + clean("final_test"), + dir("final_test/.hidden_dir1"), + dir("final_test/.hidden_dir2"), + dir("final_test/visible_dir"), + file("final_test/.secret_code", "hidden_master\n"), + file("final_test/.config", "not this one\n"), + file("final_test/.notes", "Poznámky nejsou adresář.\n"), + file("final_test/readme.txt", "Look for hidden items!\n"), + ], + answer: parts(",", &[ + int(2) + .mistakes(&[("4", "Možná počítáte i '.' a '..'. Ty vynechte; počítejte jen skryté adresáře.")]) + .error("Počet skrytých adresářů není správný. V 'ls -la' sledujte první znak řádku a název."), + exact("hidden_master") + .nocase() + .mistakes(&[("not this one", "To je obsah .config, ne .secret_code.")]) + .error("Kód není správný. Přečtěte .secret_code."), + ]) + .format("Formát odpovědi je: POČET,KÓD"), + solution: r#"shellgame submit "$(ls -la | grep -c '^d.* \.[^.]'),$(cat .secret_code)""#, + success: "Výborně! Dokončili jste Sekci 3. Skryté soubory před vámi nic neskryjí!", + ..LEVEL + }, + ], +}; diff --git a/src/levels/s04_create.rs b/src/levels/s04_create.rs new file mode 100644 index 0000000..c1a0a97 --- /dev/null +++ b/src/levels/s04_create.rs @@ -0,0 +1,390 @@ +//! Creating and deleting: `mkdir`, `touch`, `rm`, `rmdir` and `rm -r`, and why deleting is final. +//! +//! **Purpose.** Until now the student only walked through a prepared world. Here they shape it, and +//! as the shell has no trash can, care is part of the lesson: levels judge the files left behind. +//! +//! **Learning objectives.** After this section a student can: +//! - create a directory with `mkdir` and a nested path with `mkdir -p`, and say why plain `mkdir` +//! fails when a parent is missing; +//! - create empty files with `touch`, several paths at once with one `mkdir -p` or `touch`; +//! - delete a file with `rm`, an empty directory with `rmdir`, a directory with content with +//! `rm -r`, knowing there is no undo; +//! - delete several named files with one `rm` while the file that must stay stays. +//! +//! **Introduces:** `mkdir`, `mkdir -p`, `rm`, `rmdir`, `rm -r`.\ +//! **Relies on:** [navigation](super::s01_navigation), [files](super::s02_files) (`touch`).\ +//! **Leaves for later:** copying and moving ([copy_move](super::s06_copy_move)), wildcards (only +//! warned about, as in `rm *.log`: [wildcards](super::s07_wildcards)), writing file content +//! ([redirection](super::s09_redirection)). + +use crate::game::*; + +#[rustfmt::skip] +pub const SECTION: Section = Section { + title: "Vytváření a mazání", + intro: r#" + V této sekci se naučíte, jak vytvářet nové soubory a adresáře a jak po sobě uklidit. + + ### Proč je to důležité? + Zatím jste se pohybovali po struktuře, kterou někdo připravil za vás. + Každý nový projekt ale začíná prázdným adresářem, který si musíte založit sami — + a na serveru, kde není žádné grafické rozhraní, je příkazová řádka jediná možnost. + S úklidem přichází i odpovědnost: mazání v terminálu je okamžité a konečné. + + ### Životní cyklus položek + Každá položka v systému prochází třemi fázemi: + - **Vytvoření**: `touch soubor.txt` / `mkdir projekt/` + - **Práce**: úprava obsahu, čtení, navigace + - **Úklid**: `rm soubor.txt` / `rm -r projekt/` + + ### Vytváření zanořených struktur (`mkdir -p`) + Příkaz `mkdir` běžně vyžaduje, aby rodičovský adresář už existoval: + + ``` + $ mkdir a/b/c + mkdir: cannot create directory 'a/b/c': No such file or directory + ``` + ❌ **Chyba**: nadřazené složky `a` ani `b` ještě neexistují. + + S přepínačem **`-p`** (parents) vytvoří `mkdir` celou cestu najednou: + + ``` + $ mkdir -p a/b/c + ``` + ✓ **Výsledek**: automaticky se vytvoří celá větev `a/` → `a/b/` → `a/b/c/`. + + ### Co se naučíte + - Vytvářet prázdné soubory (`touch`) + - Vytvářet adresáře (`mkdir`) + - Vytvářet zanořené struktury (`mkdir -p`) + - Mazat soubory (`rm`) + - Mazat adresáře (`rmdir`, `rm -r`) + + ### ⚠️ Pozor na `rm`! + Příkaz `rm` nepřesouvá soubory do koše — maže je rovnou a nevratně. + + ### Pokračování + Pro zahájení prvního levelu této sekce stiskněte Enter. + "#, + levels: &[ + Level { + title: "Vytvoření souboru", + text: r#" + ### Cíl + Vytvořte nový prázdný soubor. + + ### Příkazy + - `touch <název_souboru>` (vytvoří prázdný soubor nebo aktualizuje časové značky) + + ### Úkol + Vytvořte prázdný soubor s názvem `novy_soubor.txt` v aktuálním adresáři. + + ### Odevzdání + Až bude soubor existovat a bude zcela prázdný, spusťte: + `shellgame submit` + "#, + hints: &[ + "Příkaz 'touch' vytvoří prázdný soubor. Jaký název má mít?", + "Syntaxe je jednoduchá: touch název_souboru", + "Použijte 'touch novy_soubor.txt', ověřte výsledek pomocí 'ls -l' a spusťte 'shellgame submit'.", + ], + start: "creation", + setup: &[clean("creation/novy_soubor.txt"), dir("creation")], + check: &[ + is_file("creation/novy_soubor.txt") + .msg("Soubor novy_soubor.txt zatím neexistuje. Vytvořte ho v adresáři, kde level začíná, a ověřte pomocí 'ls'."), + content_is("creation/novy_soubor.txt", "").msg("Soubor novy_soubor.txt existuje, ale není prázdný."), + ], + solution: r#" + touch novy_soubor.txt + shellgame submit + "#, + success: "Správně! Soubor může vzniknout úplně prázdný a obsah do něj doplníte až později.", + ..LEVEL + }, + Level { + title: "Vytvoření adresáře", + text: r#" + ### Cíl + Vytvořte nový adresář. + + ### Příkazy + - `mkdir <název_adresáře>` (make directory) + + ### Úkol + Vytvořte adresář s názvem `data` v aktuálním adresáři. + + ### Odevzdání + Až bude adresář existovat, spusťte: + `shellgame submit` + "#, + hints: &[ + "Příkaz 'mkdir' slouží k vytvoření nového adresáře.", + "Spusťte 'mkdir data' pro vytvoření adresáře data.", + "Ověřte pomocí 'ls -l' (řádek adresáře začíná 'd') a spusťte 'shellgame submit'.", + ], + start: "creation", + setup: &[clean("creation/data"), dir("creation")], + check: &[ + is_dir("creation/data") + .msg("Požadovaný adresář zatím neexistuje. Ověřte pomocí 'ls -l', že jste ho založili v aktuálním adresáři a bez překlepu v názvu."), + ], + solution: r#" + mkdir data + shellgame submit + "#, + success: "Správně! Nový adresář vznikne jediným příkazem a hned do něj můžete vstoupit.", + ..LEVEL + }, + Level { + title: "Vytváření zanořených adresářů", + text: r#" + ### Cíl + Vytvořte zanořenou strukturu adresářů jedním příkazem. + + ### Vytváření cest s přepínačem `-p` + Při vytváření celé cesty (např. `projekt/src/tests`) příkaz `mkdir` bez přepínače + selže, pokud nadřazené adresáře neexistují. + Přepínač `-p` (parents) vytvoří celou cestu včetně chybějících rodičovských adresářů. + + ### Příkazy + - `mkdir -p <cesta>` (vytvoří adresář včetně všech chybějících nadřazených složek) + + ### Úkol + Vytvořte strukturu adresářů `projekt/src/tests`. + + ### Odevzdání + Až bude celá struktura vytvořená, spusťte: + `shellgame submit` + "#, + hints: &[ + "Co se stane, když zkusíte 'mkdir projekt/src/tests' bez přepínače -p?", + "Přepínač -p (parents) vytvoří i všechny nadřazené adresáře, které chybí.", + "Použijte 'mkdir -p projekt/src/tests'.", + ], + start: "nested", + setup: &[clean("nested/projekt"), dir("nested")], + check: &[ + is_dir("nested/projekt/src/tests") + .msg("Struktura projekt/src/tests zatím není celá. Zkontrolujte ji pomocí 'ls projekt' a 'ls projekt/src': bez přepínače -p vytvoří mkdir jen poslední článek cesty, a to jen když jeho rodič už existuje. S -p vzniknou chybějící rodičovské adresáře zároveň s ním."), + ], + solution: r#" + mkdir -p projekt/src/tests + shellgame submit + "#, + success: "Správně! Přepínač -p vytvoří celou cestu naráz, takže rodiče nemusíte zakládat po jednom.", + ..LEVEL + }, + Level { + title: "Mazání souborů", + text: r#" + ### Cíl + Smažte nepotřebný soubor. + + ### Příkazy + - `rm <soubor>` (remove — smaže zadaný soubor) + + ### ⚠️ Důležité varování + Příkaz `rm` nepřesouvá soubory do koše jako grafické prostředí — maže je okamžitě a nevratně. + - `rm -i <soubor>` se před smazáním zeptá na potvrzení. + - Před hromadným mazáním (např. `rm *.log`) si obsah zkontrolujte pomocí `ls *.log`. + + ### Úkol + Smažte soubor `stary_log.txt`, který se nachází v aktuálním adresáři. + + ### Odevzdání + Až bude soubor smazaný, spusťte: + `shellgame submit` + "#, + hints: &[ + "Příkaz rm permanentně maže soubory. Jaký soubor máte smazat?", + "Syntaxe je jednoduchá: rm název_souboru", + "Použijte 'rm stary_log.txt' a spusťte 'shellgame submit'.", + ], + start: "cleanup", + setup: &[file("cleanup/stary_log.txt", "old data")], + check: &[ + no_file("cleanup/stary_log.txt") + .msg("Soubor stary_log.txt tu pořád je. Zkontrolujte pomocí 'ls', že mažete ve správném adresáři a že jste název napsali bez překlepu."), + ], + solution: r#" + rm stary_log.txt + shellgame submit + "#, + success: "Správně! Mazání v shellu je okamžité a nevratné, proto se vyplatí nejdřív ověřit, co mažete.", + ..LEVEL + }, + Level { + title: "Mazání adresářů", + text: r#" + ### Cíl + Smažte adresář i s jeho obsahem. + + ### Příkazy + - `rmdir <adresář>` (odstraní prázdný adresář; s obsahem selže) + - `rm -r <adresář>` (recursive — rekurzivně smaže adresář i s obsahem) + + ### Úkol + Smažte adresář `temp`, který obsahuje nějaké dočasné soubory. + + ### Odevzdání + Až bude adresář smazaný, spusťte: + `shellgame submit` + "#, + hints: &[ + "Zkuste nejdřív 'rmdir temp'. Co se stane?", + "Pokud adresář není prázdný, rmdir selže. Jaký přepínač potřebujete pro rekurzivní mazání?", + "Použijte 'rm -r temp' pro smazání adresáře včetně obsahu a spusťte 'shellgame submit'.", + ], + start: "cleanup", + setup: &[file("cleanup/temp/junk.txt", "junk")], + check: &[ + no_file("cleanup/temp") + .msg("Zadaný adresář tu pořád je. Pokud rmdir skončil hláškou 'Directory not empty', pracoval přesně podle očekávání: umí odstranit jen prázdný adresář a nesmí se dotknout obsahu. Adresář s obsahem proto smažte až rekurzivní variantou příkazu rm."), + ], + solution: r#" + rm -r temp + shellgame submit + "#, + success: "Správně! Prázdný adresář zvládne 'rmdir', na adresář s obsahem je potřeba rekurzivní mazání ('rm -r').", + ..LEVEL + }, + Level { + title: "Příprava projektu", + text: r#" + ### Cíl + Připravte kompletní kostru nového projektu. + + ### Příkazy k použití + - `mkdir -p <cesta>` pro adresáře + - `touch <cesta>` pro soubory + + ### Úkol + V aktuálním adresáři vytvořte adresář `web` s touto strukturou: + - `web/index.html` (soubor) + - `web/css/style.css` (soubor v podadresáři) + - `web/js` (prázdný adresář) + + ### Odevzdání + Až bude celá struktura hotová, spusťte: + `shellgame submit` + "#, + hints: &[ + "Nejdřív si rozdělte úkol na adresáře a soubory. Všechny potřebné adresáře lze vytvořit jedním příkazem.", + "Pro adresáře použijte 'mkdir -p' a pro prázdné soubory 'touch'; oba příkazy přijímají více cest.", + "Spusťte 'mkdir -p web/css web/js', potom 'touch web/index.html web/css/style.css' a nakonec 'shellgame submit'.", + ], + start: "project", + setup: &[clean("project/web"), dir("project")], + check: &[ + is_file("project/web/index.html").msg("Chybí soubor web/index.html. Prázdný soubor založí 'touch'."), + is_file("project/web/css/style.css") + .msg("Chybí soubor web/css/style.css. Nejdřív musí existovat podadresář css, teprve potom do něj lze soubor založit."), + is_dir("project/web/js").msg("Chybí adresář web/js. Má zůstat prázdný, ale existovat musí."), + ], + solution: r#" + mkdir -p web/css web/js + touch web/index.html web/css/style.css + shellgame submit + "#, + success: "Správně! Kostru projektu připravíte předem: adresáře jedním příkazem, prázdné soubory druhým.", + ..LEVEL + }, + Level { + title: "Úklid nepořádku", + text: r#" + ### Cíl + Smažte více souborů najednou a zachovejte důležitá data. + + ### Příkazy + - `rm soubor1 soubor2 ...` (smaže více souborů najednou) + + ### Úkol + Level začíná přímo v adresáři `mess`. Smažte v něm soubory `error.log`, `temp.dat` a `junk.tmp`. + Soubor `keep_me.txt` musí zůstat zachovaný. Hodnotí se výsledný stav, ne počet příkazů. + + ### Odevzdání + Až budou nepotřebné soubory pryč, spusťte: + `shellgame submit` + "#, + hints: &[ + "Příkaz 'rm' dokáže smazat více souborů najednou, stačí je uvést oddělené mezerami.", + "Spusťte 'rm error.log temp.dat junk.tmp' (nebo je smažte postupně po jednom).", + "Ověřte pomocí 'ls', že zůstal jen soubor keep_me.txt, a spusťte 'shellgame submit'.", + ], + start: "mess", + setup: &[ + file("mess/error.log", ""), + file("mess/temp.dat", ""), + file("mess/junk.tmp", ""), + file("mess/keep_me.txt", "important"), + ], + check: &[ + no_file("mess/error.log").msg("Soubor error.log stále existuje. Smažte ho příkazem 'rm'."), + no_file("mess/temp.dat").msg("Soubor temp.dat stále existuje. Smažte ho příkazem 'rm'."), + no_file("mess/junk.tmp").msg("Soubor junk.tmp stále existuje. Smažte ho příkazem 'rm'."), + content_is("mess/keep_me.txt", "important") + .msg("Soubor keep_me.txt se změnil, a ten měl zůstat netknutý. Level obnovíte příkazem 'shellgame reset'.") + .missing("Soubor keep_me.txt zmizel, a ten měl zůstat. Typickou příčinou je příliš široký argument jako 'rm *', který smaže všechno v adresáři. Příště si nejdřív vypište, co by takový příkaz zasáhl. Level obnovíte příkazem 'shellgame reset'."), + ], + solution: r#" + rm error.log temp.dat junk.tmp + shellgame submit + "#, + success: "Správně! Jeden příkaz zvládne víc cest, ale odpovídáte za to, které soubory zasáhne.", + ..LEVEL + }, + Level { + title: "Souhrn Sekce 4", + text: r#" + ### Výzva: Stavitel souborového systému + + Ukažte, že umíte vytvářet i mazat! + + ### Úkol + V `level-4/challenge`: + + 1. Vytvořte adresářovou strukturu: `myproject/src` a `myproject/docs` + 2. Vytvořte soubor `myproject/README.md` + 3. Smažte existující soubor `delete_me.txt` + 4. Smažte existující prázdný adresář `empty_dir` + + ### Shrnutí příkazů Sekce 4 + ``` + mkdir adresar → Nový adresář + mkdir -p a/b/c → Celá cesta najednou + touch soubor → Nový prázdný soubor + rm soubor → Smazat soubor + rmdir adresar → Smazat prázdný adresář + rm -r adresar → Smazat adresář s obsahem + ``` + + ### Odevzdání + Po splnění všech bodů spusťte `shellgame submit`. + "#, + hints: &[ + "Rozdělte úkol na výsledky: dva adresáře a jeden soubor vytvořit, dvě existující položky odstranit.", + "Vnořené adresáře vytvoří 'mkdir -p'; prázdný soubor 'touch'. Soubor a prázdný adresář se mažou různými příkazy.", + "Použijte 'mkdir -p myproject/src myproject/docs', 'touch myproject/README.md', 'rm delete_me.txt' a 'rmdir empty_dir'.", + ], + start: "challenge", + setup: &[clean("challenge"), dir("challenge/empty_dir"), file("challenge/delete_me.txt", "Delete this!\n")], + check: &[ + is_dir("challenge/myproject/src").msg("Chybí adresář myproject/src. Zkontrolujte vytvořenou strukturu."), + is_dir("challenge/myproject/docs").msg("Chybí adresář myproject/docs."), + is_file("challenge/myproject/README.md").msg("Chybí soubor myproject/README.md. Zkontrolujte jeho název a umístění."), + no_file("challenge/delete_me.txt").msg("Soubor delete_me.txt stále existuje."), + no_file("challenge/empty_dir").msg("Prázdný adresář empty_dir stále existuje."), + ], + solution: r#" + mkdir -p myproject/src myproject/docs + touch myproject/README.md + rm delete_me.txt + rmdir empty_dir + shellgame submit + "#, + success: "Brilantní! Dokončili jste Sekci 4. Umíte vytvářet i bourat!", + ..LEVEL + }, + ], +}; diff --git a/src/levels/s05_inspect.rs b/src/levels/s05_inspect.rs new file mode 100644 index 0000000..cdd62d5 --- /dev/null +++ b/src/levels/s05_inspect.rs @@ -0,0 +1,466 @@ +//! Examining files: what `ls -l`, `file` and `less` say about a file beyond its name. +//! +//! **Purpose.** A name or an extension is only a claim. The section teaches the student to ask the +//! system instead: how big a file is, what it really is, and what is in a file too long for `cat`. +//! +//! **Learning objectives.** After this section a student can: +//! - read a file's size in bytes from `ls -l`; find a file by its exact size; +//! - identify a file's real type with `file`, whatever its extension (an image, a text file +//! disguised as `.jpg`, a Python script), and count files by type; +//! - page through and search a long file with `less` (`/text`, `n`, `q`) instead of printing it; +//! - know that `file` does not tell whether a file may be run: that is a permission (`ls -l`). +//! +//! **Introduces:** the size column of `ls -l`, `file`, `less`.\ +//! **Relies on:** [navigation](super::s01_navigation) (`ls -l`), [files](super::s02_files) (`cat`) +//! and, ahead of [wildcards](super::s07_wildcards), `file *` and `file *.jpg` (given ready-made).\ +//! **Leaves for later:** reading and changing permissions ([permissions](super::s08_permissions)), +//! copying and moving ([copy_move](super::s06_copy_move)). + +use crate::game::*; + +#[rustfmt::skip] +pub const SECTION: Section = Section { + title: "Zkoumání souborů", + intro: r#" + V této sekci se naučíte, jak zjistit více informací o souborech, než jen jejich název. + + ### Proč je to důležité? + + #### Bezpečnost + Stáhli jste soubor `faktura.pdf` z e-mailu. Ale je to opravdu PDF? + Příkaz `file` pomůže odhalit skutečný formát a podezřelý nesoulad s názvem. + Nejde však o antivirovou ani úplnou bezpečnostní kontrolu. + + #### Praktická práce + - **Vývojář**: "Proč mi nefunguje import? Ten soubor vypadá prázdný..." → `ls -l` ukáže, že má 0 bajtů + - **Admin**: "Který log zabírá místo na disku?" → `ls -lh` ukáže velikosti čitelně + - **Admin**: "Kde je chyba v dlouhém logu?" → `less` umožní soubor procházet a prohledávat + - **Student**: "Je tohle textový soubor nebo binární?" → `file` řekne přesný typ + + ### Výstup `ls -l` vysvětlen + ``` + -rw-r--r-- 1 student users 20480 Dec 5 10:30 dokument.txt + │└┬┘└┬┘└┬┘ │ │ │ │ │ + │ │ │ │ │ │ │ │ └─ název + │ │ │ │ │ │ │ └─ datum změny + │ │ │ │ │ │ └─ velikost v bajtech + │ │ │ │ │ └─ skupina + │ │ │ │ └─ vlastník + │ │ │ └─ oprávnění pro ostatní (r--) + │ │ └─ oprávnění pro skupinu (r--) + │ └─ oprávnění pro vlastníka (rw-) + └─ typ souboru (- = soubor, d = adresář) + ``` + + --- + + ### Příkaz `file` — pravda o obsahu + Názvy souborů mohou být klamavé; příkaz `file` zkoumá skutečný obsah: + + ``` + $ file foto.jpg + foto.jpg: JPEG image data ← Opravdu obrázek + + $ file fake.jpg + fake.jpg: ASCII text ← Někdo lhal! Je to text. + ``` + + ### Co se naučíte + - Zjišťovat velikost souborů (`ls -l`, `ls -lh`) + - Procházet a prohledávat velké soubory (`less`, `/`, `q`) + - Určovat typ souboru (`file`) + - Rozlišovat mezi textovými a binárními soubory + + ### Pokračování + Pro zahájení prvního levelu této sekce stiskněte Enter. + "#, + levels: &[ + Level { + title: "Velikost souboru", + text: r#" + ### Cíl + Zjistěte přesnou velikost souboru v bajtech. + + Příkaz `ls -l` (long listing) zobrazí podrobné informace o souborech včetně jejich + velikosti v bajtech (5. sloupec). Přepínač `-h` ji zaokrouhlí a zapíše s příponou + `K`, `M` nebo `G` (kilobajty, megabajty, gigabajty), takže přesný počet bajtů s ním neuvidíte. + + ### Příkazy + - `ls -l` (zobrazí detaily; velikost je v pátém sloupci) + + ### Úkol + 1. V aktuálním adresáři je soubor `database.db`. + 2. Pomocí `ls -l` zjistěte jeho přesnou velikost v bajtech. + 3. Odevzdejte tuto velikost jako číslo. + + ### Odevzdání + `shellgame submit <bajty>` + "#, + hints: &[ + "Příkaz 'ls -l' zobrazí podrobnosti o souborech. Který sloupec obsahuje velikost?", + "Ve výstupu ls -l je velikost v bajtech - hledejte číslo před datem.", + "Použijte 'ls -l database.db' a podívejte se na pátý sloupec.", + ], + start: "sizes", + setup: &[binary("sizes/database.db", &[b'x'; 12345])], + answer: int(12345) + .mistakes(&[ + ("13", "To je zaokrouhlená velikost v kilobajtech z 'ls -lh'. Odevzdejte přesný počet bajtů z 'ls -l' bez '-h'."), + ("12", "To je velikost v kilobajtech. Odevzdejte přesný počet bajtů z 'ls -l' bez '-h'."), + ]) + .error("Odevzdejte přesný počet bajtů jako samotné číslo z pátého sloupce 'ls -l' (bez '-h' a bez jednotek)."), + solution: r#"shellgame submit "$(ls -l database.db | tr -s ' ' | cut -d' ' -f5)""#, + success: "Správně! Pátý sloupec 'ls -l' udává velikost v bajtech; '-h' ji zaokrouhlí do čitelnější podoby.", + ..LEVEL + }, + Level { + title: "Hledání podle velikosti", + text: r#" + ### Cíl + Najděte v podrobném výpisu soubor s konkrétní velikostí. + + ### Příkazy + - `ls -l` (zobrazí podrobnosti včetně velikosti v 5. sloupci) + + ### Úkol + V adresáři jsou čtyři soubory. + Pouze jeden z nich má velikost přesně **1337 bajtů**. + + 1. Spusťte `ls -l` a zkontrolujte sloupec s velikostí. + 2. Najděte soubor s velikostí přesně 1337 bajtů. + 3. Odevzdejte název tohoto souboru. + + ### Odevzdání + `shellgame submit <název_souboru>` + "#, + hints: &[ + "Příkaz 'ls -l' zobrazuje podrobný výpis souborů včetně velikosti v bajtech v pátém sloupci.", + "Spusťte 'ls -l' a hledejte řádek, kde je velikost přesně 1337.", + "Název souboru je na konci příslušného řádku. Odevzdejte ho příkazem 'shellgame submit <soubor>'.", + ], + start: "search", + setup: &[ + binary("search/file_a", &[b'x'; 1000]), + binary("search/file_b", &[b'x'; 2000]), + binary("search/file_c", &[b'x'; 1338]), + binary("search/file_d", &[b'x'; 1337]), + ], + answer: exact("file_d") + .mistakes(&[ + ("file_c", "Soubor 'file_c' má 1338 bajtů (o 1 bajt více). Hledejte přesně 1337 bajtů."), + ("file_a", "Soubor 'file_a' má 1000 bajtů. Hledejte přesně 1337 bajtů."), + ("file_b", "Soubor 'file_b' má 2000 bajtů. Hledejte přesně 1337 bajtů."), + ]), + solution: r#"shellgame submit "$(ls -l | grep ' 1337 ' | grep -o '[^ ]*$')""#, + success: "Správně! Podrobný výpis se čte po sloupcích — stačí porovnat ten jeden, který vás zajímá.", + ..LEVEL + }, + Level { + title: "Typ souboru", + text: r#" + ### Cíl + Rozpoznejte obrázek mezi soubory bez přípony podle jejich obsahu. + + V Linuxu přípona souboru (např. `.txt`, `.jpg`) neurčuje jeho typ. O tom rozhoduje obsah. + Příkaz `file` prozkoumá obsah souboru a řekne vám, o jaký typ se jedná. + + ### Příkazy + - `file <soubor>` (zjistí typ konkrétního souboru) + - `file *` (zjistí typ všech souborů v aktuálním adresáři) + + ### Úkol + V adresáři jsou tři soubory bez přípony. + Jeden z nich je obrázek (`JPEG image data`). Zjistěte který a odevzdejte jeho název. + + ### Odevzdání + `shellgame submit <soubor>` + "#, + hints: &[ + "Příkaz 'file' zkoumá obsah souboru, ne jeho název. Jak zjistíte typ všech souborů najednou?", + "Zkuste 'file *': vypíše typ každého souboru v adresáři na samostatný řádek. Hledejte 'JPEG' ve výstupu.", + "Použijte 'file *' a najděte soubor označený jako 'JPEG image data'.", + ], + start: "types", + setup: &[ + file("types/file1", "This is a text file."), + binary("types/file2", b"\x00\x01\x02\x03"), + binary("types/file3", JPEG), + ], + answer: exact("file3"), + solution: r#"shellgame submit "$(file * | grep JPEG | cut -d: -f1)""#, + success: "Správně! O typu souboru rozhoduje jeho obsah, ne název ani přípona.", + ..LEVEL + }, + Level { + title: "Hledání ve velkém souboru", + text: r#" + ### Cíl + Naučte se prohlížet a prohledávat velké soubory pomocí interaktivního nástroje `less`. + + Příkaz `cat` vypíše celý soubor najednou, což je u dlouhých souborů nepraktické. + Interaktivní stránkovač `less` umožní souborem pohodlně listovat a hledat v něm. + + ### 🚪 Jak z less odejít: klávesa `q` (Quit) + Nástroj `less` zabere celou obrazovku terminálu. Během jeho běhu nelze zadávat + běžné příkazy shellu. + **Pro ukončení prohlížeče a návrat do příkazové řádky stiskněte klávesu `q`.** + + ### Ovládání less + ``` + q → UKONČIT prohlížení (návrat do shellu) + /hledany_text → Hledat text v souboru (potvrdit Enterem) + n → Další výskyt hledaného textu + Enter / šipka dolů → Posun o jeden řádek dolů + Mezerník / Page Down → Posun o celou stránku dolů + b / Page Up → Posun o stránku nahoru + šipka nahoru → Posun o řádek nahoru + g / G → Na začátek / na konec souboru + ``` + + ### Úkol + Soubor `server.log` má 200 řádků. Najděte v něm chybový kód na řádku s textem `CRITICAL`: + + 1. Otevřete soubor: `less server.log` + 2. Hledejte: stiskněte `/`, napište `CRITICAL` a stiskněte **Enter** + 3. Přečtěte chybový kód (číslo za slovem `Code`) na konci nalezeného řádku + 4. **Stiskněte klávesu `q`** pro ukončení `less` a návrat do příkazové řádky + 5. Odešlete nalezený kód pomocí `shellgame submit` + + ### Odevzdání + `shellgame submit <číslo>` + "#, + hints: &[ + "V less použijte / pro vyhledávání. Napište /CRITICAL a stiskněte Enter.", + "Nalezený řádek obsahuje číslo na konci. Přečtěte ho.", + "Pro návrat do shellu stiskněte klávesu 'q' (quit).", + "Pozor: neodevzdáváte číslo řádku (v hranatých závorkách na začátku), ale kód na konci věty.", + ], + start: "logs", + setup: &[build(|root| write(root, "logs/server.log", server_log()))], + answer: int(42) + .mistakes(&[("137", "137 je číslo řádku, ne kód na konci. Přečtěte celý CRITICAL řádek.")]) + .error("Tohle není správný kód. Najděte řádek s 'CRITICAL' a přečtěte číslo na konci."), + solution: r#"shellgame submit "$(grep CRITICAL server.log | grep -o '[0-9]*$')""#, + success: "Správně! 'less' usnadňuje hledání ve velkých souborech: '/' hledá, 'n' skočí na další výskyt a 'q' ho ukončí.", + ..LEVEL + }, + Level { + title: "Zamaskovaný soubor", + text: r#" + ### Cíl + Najděte soubor s příponou `.jpg`, který ve skutečnosti není obrázkem. + + V adresáři `downloads` je mnoho různých stažených souborů (dokumenty, archivy, + skripty i logy). Někdo se pokusil ukrýt tajnou textovou zprávu tím, že ji pojmenoval + s příponou `.jpg`. + + ### Příkazy + - `file *.jpg` (zkontroluje pouze soubory s příponou .jpg místo všech položek) + + ### Úkol + 1. V adresáři `downloads` je mnoho různých souborů. Spuštění `file *` by vypsalo + desítky položek bez užitku. + 2. Pomocí `file *.jpg` zkontrolujte pouze soubory s příponou `.jpg`. + 3. Najděte, který soubor je ve skutečnosti textový (`ASCII text`). + 4. Odevzdejte název tohoto souboru. + + ### Odevzdání + `shellgame submit <soubor>` + "#, + hints: &[ + "Příkaz 'file' zkoumá skutečný obsah souboru bez ohledu na jeho příponu.", + "Místo 'file *' použijte 'file *.jpg' — zkontrolujete jen soubory s příponou .jpg.", + "Hledejte mezi soubory .jpg ten, u kterého 'file' vypíše 'ASCII text'. Odevzdejte jeho název.", + ], + start: "downloads", + setup: &[ + binary("downloads/photo1.jpg", JPEG), + binary("downloads/photo2.jpg", JPEG), + binary("downloads/photo3.jpg", JPEG), + binary("downloads/photo4.jpg", JPEG), + binary("downloads/photo5.jpg", JPEG), + binary("downloads/vacation.jpg", JPEG), + binary("downloads/sunset.jpg", JPEG), + binary("downloads/wallpaper.jpg", JPEG), + binary("downloads/avatar.jpg", JPEG), + binary("downloads/nature.jpg", JPEG), + file("downloads/secret.jpg", "This is actually a secret text file.\n"), + binary("downloads/manual.pdf", b"%PDF-1.4\n%EOF\n"), + binary("downloads/archive.zip", b"PK\x03\x04\x14\x00\x00\x00\x08\x00"), + binary("downloads/backup.tar.gz", b"\x1f\x8b\x08\x00\x00\x00\x00\x00"), + binary("downloads/music.mp3", b"ID3\x03\x00\x00\x00"), + file("downloads/notes.txt", "Random notes and meeting minutes.\n"), + file("downloads/install.sh", r#" + #!/bin/sh + echo 'installing...' + "#), + file("downloads/setup.log", "[INFO] Setup initialized.\n"), + file("downloads/table.csv", r#" + id,name,value + 1,alpha,100 + "#), + file("downloads/data.json", r#" + {"status": "ok", "count": 42} + "#), + file("downloads/config.yaml", r#" + env: production + debug: false + "#), + file("downloads/readme.md", r#" + # Downloads Readme + Just downloads. + "#), + file("downloads/styles.css", "body { margin: 0; padding: 0; }\n"), + file("downloads/index.html", "<!DOCTYPE html><html><body>Test</body></html>\n"), + file("downloads/script.py", r#" + #!/usr/bin/env python3 + print('hello') + "#), + file("downloads/checksums.sha256", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n"), + binary("downloads/report.doc", b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"), + binary("downloads/app.bin", b"\x7fELF\x02\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00"), + ], + answer: exact("secret.jpg"), + solution: r#"shellgame submit "$(file *.jpg | grep 'ASCII text' | cut -d: -f1)""#, + success: "Správně! Přípona je jen dohoda — 'file' čte skutečný obsah, a tak odhalí i zamaskovaný soubor.", + ..LEVEL + }, + Level { + title: "Rozpoznání skriptu v Pythonu", + text: r#" + ### Cíl + Rozpoznejte skript v Pythonu mezi různými typy souborů. + + Příkaz `file` dokáže podle obsahu spolehlivě rozpoznat skript v Pythonu bez ohledu na jeho název. + Popis `Python script` ale neříká, zda má soubor právo ke spuštění (`x`) — typ zjišťuje `file`, + kdežto oprávnění zobrazuje `ls -l` a mění `chmod` (tomu se věnuje sekce „Oprávnění“). + + ### 💡 Pozor na slovo `executable` + U skriptu vypíše `file` například `Python script, ASCII text executable`. Slovo `executable` + tu znamená jen to, že soubor začíná řádkem `#!` (shebang), který říká, jakým programem se má + skript spustit. O oprávněních nic neříká: když spustíte `ls -l`, uvidíte, že žádný soubor + v tomto adresáři právo `x` nemá. + + ### Příkazy + - `file *` (vypíše typy všech souborů v aktuálním adresáři) + + ### Úkol + Nacházíte se přímo v adresáři `bin` (nikam nemusíte přecházet): + 1. Prozkoumejte soubory v aktuálním adresáři pomocí `file *`. + 2. Najděte soubor, který příkaz `file` označí jako `Python script`. + 3. Odevzdejte název tohoto souboru. + + ### Odevzdání + `shellgame submit <soubor>` + "#, + hints: &[ + "Jste přímo v adresáři 'bin'. Spusťte 'file *' pro zjištění typů všech souborů.", + "Spusťte 'file *' a hledejte soubor, u kterého výstup uvádí 'Python script'.", + "Odevzdejte název souboru označeného jako 'Python script'; slovo 'executable' v popisu neznamená právo x.", + ], + start: "bin", + setup: &[ + file("bin/readme.txt", "Just text."), + file("bin/run.sh", r#" + #!/bin/bash + echo hello + "#), + file("bin/calc.py", r#" + #!/usr/bin/env python3 + print(1+1) + "#), + binary("bin/program", b"\x7fELF"), + ], + answer: suffix("calc.py") + .mistakes(&[ + ("calc", "Odevzdejte celý název souboru i s příponou."), + ("readme.txt", "To je prostý text, ne skript. Řiďte se přesným popisem z 'file *', ne příponou názvu."), + ("run.sh", "To je shellový skript ('shell script'), ne Python. Přečtěte popis z 'file *' celý."), + ("program", "To je zkompilovaný program (ELF), ne skript. Porovnejte popisy z 'file *'."), + ]) + .error("To není skript v Pythonu. Rozhoduje přesný popis z 'file *', ne přípona názvu."), + solution: r#"shellgame submit "$(file * | grep 'Python script' | cut -d: -f1)""#, + success: "Správně! 'file' určí typ souboru podle obsahu; slovo 'executable' znamená jen řádek '#!', právo ke spuštění ukáže až 'ls -l'.", + ..LEVEL + }, + Level { + title: "Souhrn Sekce 5", + text: r#" + ### Výzva: Detektiv souborů + + Ukažte, že umíte identifikovat typy souborů! + + ### Úkol + V `level-5/mystery` je 5 souborů s podivnými názvy. + Zjistěte typ každého a odpovězte na otázky: + + 1. Kolik je tam **prostých textových** souborů (popis začíná `ASCII text`, bez skriptů)? + 2. Kolik je tam **obrázků** (image)? + 3. Jaký je název jediného skriptu v **Pythonu** (bez cesty)? + + ### Formát odpovědi + `<počet_textových>,<počet_obrázků>,<název_skriptu>` + + Skript počítejte samostatně, i když jeho popis také obsahuje `ASCII text`. + + ### Shrnutí příkazů Sekce 5 + ``` + file soubor → Zjistí typ souboru + file * → Typy všech souborů + file -b soubor → Jen typ bez názvu + ``` + + ### Odevzdání + `shellgame submit <počet_textových>,<počet_obrázků>,<název_skriptu>` + "#, + hints: &[ + "Použijte 'file *' k zobrazení typů všech souborů najednou.", + "Pro prostý text hledejte popis začínající 'ASCII text'. Skripty do tohoto počtu nepatří.", + "Popis s 'image' znamená obrázek. Popis s 'Python script' znamená skript v Pythonu, ne prostý text.", + ], + start: "mystery", + setup: &[ + clean("mystery"), + file("mystery/data.bin", "This is just plain text.\n"), + file("mystery/notes.xyz", "More text content.\n"), + binary("mystery/config.txt", b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\x0cIDATx\x9cc```\x00\x00\x00\x04\x00\x01\xf6\x178U\x00\x00\x00\x00IEND\xaeB`\x82"), + file("mystery/analyzer.dat", r#" + #!/usr/bin/env python3 + import sys + print('Hello') + "#), + binary("mystery/readme.doc", b"\x7fELF\x02\x01\x01"), + ], + answer: parts(",", &[ + int(2) + .error("Počet prostých textových souborů není správný. Počítejte popisy začínající 'ASCII text', nikoli skripty."), + int(1) + .error("Počet obrázků není správný. Hledejte 'image' ve výstupu 'file *'."), + exact("analyzer.dat").nocase().error("Název skriptu v Pythonu není správný. Hledejte 'Python' ve výstupu 'file *'."), + ]) + .format("Formát: počet_textových,počet_obrázků,název_skriptu"), + solution: r#"shellgame submit "$(file -b * | grep -c '^ASCII text'),$(file -b * | grep -c image),$(file * | grep 'Python script' | cut -d: -f1)""#, + success: "Skvělá detektivní práce! Dokončili jste Sekci 5. Příponám se už nedáte zmást!", + ..LEVEL + }, + ], +}; + +const JPEG: &[u8] = b"\xff\xd8\xff\xe0\x00\x10JFIF"; + +/// The 200 lines of `logs/server.log`: routine `INFO` lines, a `WARNING` every tenth line, an `ERROR` +/// every seventh, and one `CRITICAL` line (137) that carries the code to find. +fn server_log() -> String { + let lines: Vec<String> = (1..=200) + .map(|line| { + let entry = match line { + 137 => "CRITICAL: System failure detected - Code 42", + n if n % 10 == 0 => "WARNING: High memory usage", + n if n % 7 == 0 => "ERROR: Connection timeout", + _ => "INFO: Normal operation", + }; + format!("[{line:03}] {entry}") + }) + .collect(); + lines.join("\n") + "\n" +} diff --git a/src/levels/s06_copy_move.rs b/src/levels/s06_copy_move.rs new file mode 100644 index 0000000..83ac6da --- /dev/null +++ b/src/levels/s06_copy_move.rs @@ -0,0 +1,405 @@ +//! Copying and moving: `cp`, `cp -r` and `mv`, a copy versus a move, and an alias for `shellgame`. +//! +//! **Purpose.** `cp` and `mv` can overwrite data without a word, so they come with the habit of +//! checking the target. The section also hands over a first convenience: an alias for `shellgame`. +//! +//! **Learning objectives.** After this section a student can: +//! - copy a file (`cp source target`, the original stays) and a directory (`cp -r`); +//! - rename (`mv old new`) and move into a directory (`mv file dir/`), knowing the form of the +//! target decides which happens; move a directory with `mv`, which needs no `-r`; +//! - move a set of files with one pattern (`mv *.log logs/`), previewed with `ls`; +//! - choose copy or move per file in a small tidy-up task; +//! - define an alias (`alias hra='shellgame'`; fish: `alias hra shellgame`), check it with +//! `type hra`, and ask `type` what a name is to the shell (`shellgame` itself is a function). +//! +//! **Introduces:** `cp`, `cp -r`, `mv`, `alias`, `type`, and a first `*`.\ +//! **Relies on:** [navigation](super::s01_navigation) (`ls`, relative paths); `-r` was met in +//! `rm -r` ([create](super::s04_create)).\ +//! **Leaves for later:** the rest of the wildcard family ([wildcards](super::s07_wildcards), +//! announced in the level text); `-i`, which the intro only names. + +use crate::game::*; + +#[rustfmt::skip] +pub const SECTION: Section = Section { + title: "Kopírování a přesouvání", + intro: r#" + V této sekci se naučíte manipulovat se soubory a adresáři - vytvářet jejich kopie a přesouvat je na jiná místa. + + ### Proč je to důležité? + „Před úpravou konfigurace si udělej zálohu.“ — rada, kterou uslyšíte všude, + a která se v terminálu vejde do jednoho příkazu. + + `cp` a `mv` jsou zároveň první příkazy, které doopravdy přepisují vaše data. + Oba umí bez varování přepsat existující cíl — proto se už teď vyplatí + zvyknout si nejdřív zkontrolovat, co na cílovém místě leží. + + ### `cp` vs `mv` - jaký je rozdíl? + ``` + cp (copy) mv (move) + ───────────────────────────────────────────── + Původní soubor ZŮSTANE Původní soubor ZMIZÍ + Vytvoří duplikát Přesune/přejmenuje + + cp a.txt b.txt mv a.txt b.txt + ├── a.txt ← zůstává └── b.txt ← jediný soubor + └── b.txt ← nový + ``` + + ### Pozor na existující cíl + `cp` i `mv` mohou bez dalšího dotazu přepsat existující cílový soubor. + Předem zkontrolujte cíl pomocí `ls`, případně použijte `cp -i` nebo `mv -i`. + + ### Přejmenování = přesun na stejném místě + ``` + mv stary_nazev.txt novy_nazev.txt + │ │ + └──────────────────┘ + "přesun" = přejmenování + ``` + + ### Co se naučíte + - Kopírovat soubory (`cp`) + - Kopírovat adresáře (`cp -r`) + - Přejmenovávat soubory a adresáře (`mv`) + - Přesouvat soubory a adresáře (`mv`) + - Zkrátit si dlouhý příkaz aliasem (`alias`) a ověřit ho (`type`) + + ### Tip: `-r` znamená "rekurzivně" + `cp` a `rm` potřebují u adresářů `-r`, aby prošly celý strom včetně obsahu. `mv` ho nepotřebuje. + "#, + levels: &[ + Level { + title: "Kopírování souboru", + text: r#" + Příkaz `cp` (copy) vytvoří kopii souboru. + + ### Proč zálohovat? + Před úpravou důležitého souboru je dobré si udělat zálohu. + Když něco pokazíte, máte se kam vrátit! + + V praxi uvidíte: + - `cp config.yaml config.yaml.bak` (před úpravou konfigurace) + - `cp report.docx report_v1.docx` (verzování dokumentů) + + ### Úkol + 1. Začínáte v `level-6/copying`. Vytvořte zálohu souboru `dulezite.txt`. Kopii pojmenujte `dulezite.bak`. + 2. Ověřte výsledek pomocí `ls`. + + ### Příkazy + - `cp <zdroj> <cíl>`: Zkopíruje zdroj do cíle + + ### Odevzdání + Po vytvoření zálohy spusťte: + `shellgame submit` + "#, + hints: &[ + "Příkaz cp má dva argumenty: odkud a kam kopírujete.", + "Syntaxe je: cp zdrojový_soubor cílový_soubor", + "Použijte 'cp dulezite.txt dulezite.bak'.", + ], + start: "copying", + setup: &[clean("copying/dulezite.bak"), file("copying/dulezite.txt", "Very important data.")], + check: &[same("copying/dulezite.txt", "copying/dulezite.bak")], + solution: r#" + cp dulezite.txt dulezite.bak + shellgame submit + "#, + success: "Správně! Záloha je samostatná kopie pod novým jménem, originál zůstává nedotčený.", + ..LEVEL + }, + Level { + title: "Kopírování adresáře", + text: r#" + Bez přepínače `-r` (recursive) příkaz `cp` adresář vůbec nezkopíruje, jen ohlásí, že ho vynechal. + S přepínačem zkopíruje adresář i s celým obsahem. + + ### Úkol + Zkopírujte celý adresář `projekt` do nového adresáře `projekt_zaloha`. + + ### Příkazy + - `cp -r <zdroj> <cíl>`: Zkopíruje adresář + + ### Odevzdání + Po zkopírování adresáře spusťte: + `shellgame submit` + "#, + hints: &[ + "Pro kopírování celého adresáře včetně obsahu je nutné použít rekurzivní přepínač '-r'.", + "Tvar je 'cp -r <zdroj> <cíl>': přepínač stojí před oběma argumenty, zdrojový adresář je první a název nové kopie druhý.", + "Spusťte 'cp -r projekt projekt_zaloha'.", + ], + start: "copying", + setup: &[clean("copying/projekt_zaloha"), file("copying/projekt/main.py", "print('hello')")], + check: &[same("copying/projekt", "copying/projekt_zaloha")], + solution: r#" + cp -r projekt projekt_zaloha + shellgame submit + "#, + success: "Správně! Přepínač -r říká příkazu cp, že má sestoupit do celého stromu adresáře.", + ..LEVEL + }, + Level { + title: "Přejmenování souboru", + text: r#" + Příkaz `mv` (move) se používá k přesouvání, + ale pokud přesouváte soubor ve stejném adresáři na nové jméno, jde o přejmenování. + + ### Úkol + Soubor `spatne_jmeno.txt` má překlep. Přejmenujte ho na `spravne_jmeno.txt`. + + ### Příkazy + - `mv <staré_jméno> <nové_jméno>`: Přejmenuje soubor + + ### Odevzdání + Po přejmenování spusťte: + `shellgame submit` + "#, + hints: &[ + "Příkaz 'mv' slouží nejen k přesunu, ale i k přejmenování souboru: 'mv staré nové'.", + "Oba argumenty jsou jména souborů v aktuálním adresáři: první je to současné, druhé to požadované.", + "Spusťte 'mv spatne_jmeno.txt spravne_jmeno.txt'.", + ], + start: "moving", + setup: &[clean("moving/spravne_jmeno.txt"), file("moving/spatne_jmeno.txt", "content")], + check: &[moved("moving/spatne_jmeno.txt", "moving/spravne_jmeno.txt")], + solution: r#" + mv spatne_jmeno.txt spravne_jmeno.txt + shellgame submit + "#, + success: "Správně! Přejmenování je jen přesun na novou cestu ve stejném adresáři.", + ..LEVEL + }, + Level { + title: "Přesun souboru", + text: r#" + Pokud jako cíl příkazu `mv` uvedete existující adresář, + soubor se do něj přesune (a zachová si své jméno, pokud neuvedete jiné). + + ### Úkol + Přesuňte soubor `report.pdf` do adresáře `dokumenty`. + + ### Příkazy + - `mv <soubor> <adresář>/`: Přesune soubor do adresáře + + ### Proč to lomítko na konci? + Pokud cílový adresář **neexistuje**, `mv` soubor tiše přejmenuje na zadané jméno. + S lomítkem `mv` místo toho ohlásí chybu, takže se o překlepu v názvu adresáře dozvíte hned. + + ### Odevzdání + Po přesunu souboru spusťte: + `shellgame submit` + "#, + hints: &[ + "O tom, co 'mv' udělá, rozhoduje podoba cíle: soubor znamená přejmenování, existující adresář přesun.", + "Tvar je 'mv <soubor> <cílový_adresář>/': první argument je přesouvaný soubor, druhý cílový adresář zakončený lomítkem.", + "Spusťte 'mv report.pdf dokumenty/'. Lomítko na konci značí adresář.", + ], + start: "moving", + setup: &[clean("moving/dokumenty/report.pdf"), dir("moving/dokumenty"), file("moving/report.pdf", "report data")], + check: &[ + moved("moving/report.pdf", "moving/dokumenty/report.pdf") + .msg("report.pdf je stále na původním místě. Kopie originál ponechává, přesun ho odtud odebere.") + .missing("report.pdf z původního místa zmizel, ale v cílovém adresáři není. Když cíl neexistuje, 'mv' soubor jen přejmenuje - zkontrolujte 'ls'."), + ], + solution: r#" + mv report.pdf dokumenty/ + shellgame submit + "#, + success: "Správně! Podoba cíle rozhoduje, jestli mv přejmenovává, nebo přesouvá.", + ..LEVEL + }, + Level { + title: "Přesun adresáře", + text: r#" + Příkaz `mv` přesouvá celé adresáře včetně jejich obsahu; přepínač `-r` nepotřebuje. + + ### Úkol + Přesuňte celý adresář `projekt` do existujícího adresáře `archiv`. + + ### Příkazy + - `mv <adresář> <cílový_adresář>/` + + ### Odevzdání + Až bude `projekt` uvnitř `archiv`, spusťte: + `shellgame submit` + "#, + hints: &[ + "Adresář se přesouvá stejným příkazem jako soubor: 'mv' vezme celý podstrom a přepínač '-r' nepotřebuje.", + "Tvar je 'mv <adresář> <cílový_adresář>/': první argument je přesouvaný adresář, druhý už existující rodič, do kterého má vklouznout.", + "Spusťte 'mv projekt archiv/' a ověřte výsledek pomocí 'ls archiv/'.", + ], + start: "moving", + setup: &[ + clean("moving/projekt"), + clean("moving/archiv/projekt"), + dir("moving/archiv"), + file("moving/projekt/data/file.txt", "content"), + ], + check: &[ + moved("moving/projekt", "moving/archiv/projekt") + .msg("Adresář projekt je stále na původním místě - zatím se nic nepřesunulo.") + .missing("Projekt z původního místa zmizel, ale v archivu není. Když cílový adresář neexistuje, 'mv' zdroj jen přejmenuje - porovnejte 'ls' a 'ls archiv/'."), + content_is("moving/archiv/projekt/data/file.txt", "content").msg("Přesunutý adresář nemá původní obsah."), + ], + solution: r#" + mv projekt archiv/ + shellgame submit + "#, + success: "Správně! Přesun adresáře vezme celý jeho obsah s sebou jedním krokem.", + ..LEVEL + }, + Level { + title: "Úklid logů", + text: r#" + V adresáři je nepořádek. Všechny soubory s příponou `.log` by měly být v adresáři `logs`. + + ### Úkol + Přesuňte všechny `.log` soubory (`app.log`, `error.log`) do adresáře `logs`. + Zvládnete to jedním příkazem, když místo výčtu jmen zadáte vzor se žolíkem `*`. + + ### Příkazy + - `mv VZOR logs/`: za `VZOR` dosaďte vzor, který vybere právě `.log` soubory + + Vzor si nejdřív ověřte pomocí `ls VZOR` - uvidíte přesně ty soubory, které by `mv` přesunul. + Znak `*` je žolík (zástupný znak); celou rodinu žolíků probereme v sekci „Žolíky (Wildcards)“. + + ### Odevzdání + Po přesunu zkontrolujte výsledek pomocí `ls logs` a spusťte: + `shellgame submit` + "#, + hints: &[ + "Žolík '*' nahradí libovolný počet znaků, takže vzor '*.log' vybere všechny soubory s touto příponou.", + "Příkaz 'mv' dokáže přesunout více souborů najednou do cílového adresáře: 'mv <soubory> <cíl>/'.", + "Spusťte 'mv *.log logs/' a potom 'shellgame submit'.", + ], + start: "organize", + setup: &[ + clean("organize/logs"), + dir("organize/logs"), + file("organize/app.log", "log1"), + file("organize/error.log", "log2"), + file("organize/other.txt", "keep me"), + ], + check: &[ + moved("organize/app.log", "organize/logs/app.log"), + moved("organize/error.log", "organize/logs/error.log"), + is_file("organize/other.txt").msg("other.txt není log, a přesto zmizel. Vzor vybral víc souborů, než měl; ověřte ho pomocí 'ls' a začněte znovu po 'shellgame reset'."), + ], + solution: r#" + mv *.log logs/ + shellgame submit + "#, + success: "Správně! Vzor se žolíkem rozbalí shell na seznam jmen ještě před spuštěním příkazu.", + ..LEVEL + }, + Level { + title: "Rychlejší práce: alias", + text: r#" + ### Cíl + Zkraťte si časté psaní příkazu `shellgame` pomocí aliasu `hra`. + + ### Nastavení + Použijte příkaz pro svůj shell: + - **Bash:** `alias hra='shellgame'` + - **Fish:** `alias hra shellgame` + + Alias ověřte příkazem `type hra`. Příkaz `type` prozradí, čím je dané jméno pro shell: + například alias, vestavěný příkaz nebo program uložený na disku. Ve fish je alias + ve skutečnosti funkce, proto u něj `type hra` vypíše, že jde o funkci. + + Alias platí jen v aktuálním herním shellu. Po jeho ukončení zmizí, + a když hru spustíte znovu, musíte ho zadat znovu. + + ### Úkol + Zjistěte příkazem `type shellgame`, čím je pro váš herní shell samotné `shellgame`: + stačí slovo, které na prvním řádku výpisu následuje po „is a“ (v češtině po „je“). + + ### Odevzdání + Odpověď odevzdejte přes nový alias: + `hra submit <slovo>` + + V dalších levelech můžete místo dlouhého `shellgame` používat kratší `hra`. + "#, + hints: &[ + "Alias dává dlouhému příkazu kratší jméno. Vyberte syntaxi pro svůj shell a ověřte ji pomocí 'type hra'.", + "Spusťte 'type shellgame'. První řádek výpisu má tvar 'shellgame is a <slovo>' (česky 'shellgame je <slovo>').", + "Odevzdejte to slovo přes alias: 'hra submit <slovo>'.", + ], + answer: one_of(&["function", "funkce", "funkci", "a function"]) + .nocase() + .error("Nesprávně. Odevzdejte slovo, které 'type shellgame' vypíše po 'is a' (česky po 'je').") + .format("Odevzdejte slovo z výpisu 'type shellgame' přes alias: hra submit <slovo>"), + solution: r#" + test -n "$BASH_VERSION" && shopt -s expand_aliases + alias hra='shellgame' + type hra + hra submit "$(type -t shellgame)" + "#, + success: "Výborně! Příkaz type ukáže, co se skrývá za jménem příkazu. Alias hra vám teď ušetří psaní.", + ..LEVEL + }, + Level { + title: "Výzva: Organizátor souborů", + text: r#" + Ukažte, že ovládáte kopírování a přesouvání! + + ### Úkol + V aktuálním adresáři: + + 1. **Zkopírujte** `original.txt` do adresáře `backup/` (zachovejte originál) + 2. **Přejmenujte** `temp_data.csv` na `data.csv` + 3. **Přesuňte** `misplaced.log` do adresáře `logs/` + + ### Přehled příkazů + ``` + cp zdroj cíl → Kopíruje soubor + cp zdroj dir/ → Kopíruje do adresáře + cp -r dir1 dir2 → Kopíruje celý adresář + mv zdroj cíl → Přesune/přejmenuje + mv soubor dir/ → Přesune do adresáře + ``` + + ### Odevzdání + Po splnění všech bodů spusťte `shellgame submit`. + "#, + hints: &[ + "Rozhodněte u každého souboru, zda musí původní cesta zůstat zachovaná. Podle toho zvolte 'cp' nebo 'mv'.", + "Přejmenování i přesun používají 'mv'; rozdíl určuje podoba cíle. Kopie do adresáře zachová původní soubor.", + "Použijte 'cp original.txt backup/', 'mv temp_data.csv data.csv' a 'mv misplaced.log logs/'.", + ], + start: "final_test", + setup: &[ + clean("final_test"), + dir("final_test/backup"), + dir("final_test/logs"), + file("final_test/original.txt", "Important data - do not delete!\n"), + file("final_test/temp_data.csv", r#" + col1,col2 + 1,2 + "#), + file("final_test/misplaced.log", "Log entry\n"), + ], + check: &[ + is_file("final_test/original.txt").msg("Smazali jste original.txt! Měli jste ho zkopírovat, ne přesunout."), + same("final_test/original.txt", "final_test/backup/original.txt") + .msg("Kopie v backup/ neodpovídá originálu.") + .missing("Chybí kopie original.txt v adresáři backup/."), + moved("final_test/temp_data.csv", "final_test/data.csv") + .msg("temp_data.csv stále existuje. Přejmenujte ho na data.csv pomocí 'mv'.") + .missing("temp_data.csv zmizel, ale data.csv neexistuje. Pomocí 'ls' zjistěte, pod jakým jménem soubor skončil."), + moved("final_test/misplaced.log", "final_test/logs/misplaced.log") + .msg("misplaced.log je stále v aktuálním adresáři. Přesuňte ho do logs/ pomocí 'mv'.") + .missing("Chybí misplaced.log v adresáři logs/."), + ], + solution: r#" + cp original.txt backup/ + mv temp_data.csv data.csv + mv misplaced.log logs/ + shellgame submit + "#, + success: "Výborně! Dokončili jste Sekci 6 — kopírování, přesun i přejmenování máte pod kontrolou.", + ..LEVEL + }, + ], +}; diff --git a/src/levels/s07_wildcards.rs b/src/levels/s07_wildcards.rs new file mode 100644 index 0000000..98571e1 --- /dev/null +++ b/src/levels/s07_wildcards.rs @@ -0,0 +1,364 @@ +//! Wildcards: `*`, `?` and `[...]` patterns that the shell expands before the command runs. +//! +//! **Purpose.** One pattern instead of a list of names: the basis of batch work. The shell, not the +//! command, expands a pattern, so `ls PATTERN` is a free preview of what a command would touch. +//! +//! **Learning objectives.** After this section a student can: +//! - select files with `*` (every `.jpg`), `?` (one character: `data?.txt` takes `data1.txt`, not +//! `data10.txt`) and `[ab]` (one of the listed characters); +//! - use a POSIX class such as `[[:lower:]]` where a range like `[a-z]` may depend on the locale; +//! - preview a pattern with `ls` before `cp` acts on it, to catch one that selects too much; +//! - run a bash-only pattern from fish by wrapping the command, and its `ls` preview, in +//! `bash -c '...'`; +//! - pick the right kind of pattern for each of three selections and check the resulting counts. +//! +//! **Introduces:** `*`, `?`, `[...]`, POSIX classes (`[[:lower:]]`, `[[:digit:]]`), `bash -c`.\ +//! **Relies on:** [copy_move](super::s06_copy_move) (`cp`, and the first `*` met there), +//! [inspect](super::s05_inspect) (`file *`).\ +//! **Leaves for later:** not in the course: deleting by pattern (only warned about in +//! [create](super::s04_create)), negated sets `[!...]`, `**`, brace expansion, patterns that match +//! nothing. + +use crate::game::*; + +#[rustfmt::skip] +pub const SECTION: Section = Section { + title: "Žolíky (Wildcards)", + intro: r#" + V této sekci se naučíte pracovat s více soubory najednou pomocí žolíků (wildcards). + Žolíky vám umožní definovat vzory názvů souborů. + + ### Kdy potřebujete Bash? + Hvězdička `*` funguje v Bashi i ve fish. Otazník `?`, množiny `[...]` a třídy znaků + jako `[[:lower:]]` jsou ale syntaxe Bashe: fish hranaté závorky jako žolík nezná + a otazník od verze 4 také ne. Levely s nimi i souhrn sekce proto vyžadují Bash. + + Pokud hrajete ve fish, každý z těchto levelů nabídne příkaz ve tvaru + `bash -c 'příkaz'`. Ten spustí pouze daný příkaz v Bashi a vrátí vás do hry. + Vnější jednoduché uvozovky zachovají žolíky pro Bash. Stejně si ověříte i výběr + vzoru: `bash -c 'ls VZOR'`. + `shellgame submit` pak zadejte jako obvykle ve svém herním shellu. + + ### Přehled žolíků v Bashi + ``` + * Jakýkoliv počet znaků (včetně nuly) + ? Právě jeden znak + [...] Jeden ze znaků v závorkách + [a-z] Rozsah znaků (může záviset na locale) + [[:lower:]] Jeden znak klasifikovaný jako malé písmeno + ``` + + ### Příklady pro Bash + ``` + *.txt → všechny .txt soubory + data?.csv → data1.csv, data2.csv, ale NE data10.csv + file_[ab].md → file_a.md, file_b.md, ale NE file_c.md + [[:lower:]]*.py → soubory začínající malým písmenem + ``` + + > ⚠️ **Pozor na rozsahy:** `[a-z]` a `[A-Z]` mohou vycházet z pořadí znaků + > nastaveného locale, například ve starších verzích Bashe (3.2 na macOS). + > Pro význam „malé písmeno“ proto v levelu o třídách znaků použijete + > POSIX třídu `[[:lower:]]`; podobně existují `[[:upper:]]` a `[[:digit:]]`. + + ### Jak to funguje? + ``` + Vy napíšete: Shell expanduje na: + cp *.jpg imgs/ cp foto1.jpg foto2.jpg foto3.jpg imgs/ + │ │ + └── žolík ──────────┘ skutečné soubory + ``` + + ### Co se naučíte + - Vybírat soubory hvězdičkou (`*`) + - Přesně jeden znak otazníkem (`?`) + - Množinu znaků hranatými závorkami (`[abc]`) + - Rozsahy a locale-odolné třídy znaků (`[a-z]`, `[[:lower:]]`, `[[:digit:]]`) + + ### Pokračování + Pro zahájení prvního levelu této sekce stiskněte Enter. + "#, + levels: &[ + Level { + title: "Hvězdička *", + text: r#" + Hvězdička `*` nahradí JAKOUKOLIV sekvenci znaků (včetně prázdné). + Je velmi užitečná pro výběr souborů se specifickou příponou. + + Potkali jste ji už dříve (`file *`, `mv *.log logs/`); tady si její pravidla poprvé pojmenujeme přesně. + + ### Proč je to užitečné + Představte si, že máte 100 fotografií a chcete je všechny zkopírovat. + Místo `cp foto1.jpg foto2.jpg foto3.jpg ...` stačí `cp *.jpg cíl/`. + + ### Úkol + Zkopírujte všechny soubory s příponou `.jpg` do adresáře `images` + (adresář `images` již existuje). Originály zůstanou na svém místě. + + ### Příkazy + - `cp *.jpg adresář/` - zkopíruje všechny .jpg soubory + + ### Odevzdání + Po splnění úkolu odevzdejte: `shellgame submit` + "#, + hints: &[ + "Hvězdička (*) nahrazuje libovolný počet znaků.", + "Příkaz 'ls *.jpg' vypíše všechny soubory s příponou .jpg.", + "Použijte 'cp *.jpg images/' pro zkopírování všech souborů s příponou .jpg do adresáře images.", + ], + start: "star", + setup: &[ + clean("star/images"), + dir("star/images"), + file("star/photo1.jpg", ""), + file("star/photo2.jpg", ""), + file("star/notes.txt", ""), + ], + check: &[ + same("star/photo1.jpg", "star/images/photo1.jpg"), + same("star/photo2.jpg", "star/images/photo2.jpg"), + no_file("star/images/notes.txt").msg("V images/ je i notes.txt: vzor vybral víc, než měl. Soubor odstraňte, nebo začněte znovu po 'shellgame reset'."), + ], + solution: r#" + cp *.jpg images/ + shellgame submit + "#, + success: "Správně! Jediný vzor s hvězdičkou zastoupil libovolný zbytek názvu, takže jste nemuseli soubory vypisovat po jednom.", + ..LEVEL + }, + Level { + title: "Otazník ?", + text: r#" + Otazník `?` nahradí PRÁVĚ JEDEN znak. + Je užitečný, když chcete být přesnější než s hvězdičkou. + + ### Požadavek: Bash + Tento level vyžaduje **Bash**. Ve fish použijte variantu s `bash -c` níže. + Ta spustí pouze daný příkaz v Bashi; odevzdávejte dál ve svém herním shellu. + + ### Rozdíl od hvězdičky + - `*` = libovolný počet znaků (0 nebo více) + - `?` = přesně jeden znak + + ### Úkol + Zkopírujte `data1.txt` a `data2.txt` do adresáře `short_data/`. + NEKOPÍRUJTE `data10.txt` (má dvouciferné číslo). + + ### Příkazy + - V Bashi: `cp VZOR short_data/` - VZOR sestavte sami z `data`, žolíku a `.txt` + - Z fish: `bash -c 'cp VZOR short_data/'` - uvozovky ponechte + + Vzor nejdřív ověřte pomocí `ls VZOR` (z fish `bash -c 'ls VZOR'`): + vypsat se smějí jen soubory, které chcete zkopírovat. + + ### Odevzdání + Po splnění úkolu odevzdejte: `shellgame submit` + "#, + hints: &[ + "Otazník nahradí právě jeden znak. Kolik znaků je mezi 'data' a '.txt' v data1.txt?", + "data?.txt zachytí data1.txt a data2.txt, ale ne data10.txt (tam jsou dva znaky).", + r#"V Bashi spusťte "cp data?.txt short_data/", z fish "bash -c 'cp data?.txt short_data/'"."#, + ], + start: "question", + setup: &[ + clean("question/short_data"), + dir("question/short_data"), + file("question/data1.txt", ""), + file("question/data2.txt", ""), + file("question/data10.txt", ""), + ], + check: &[ + same("question/data1.txt", "question/short_data/data1.txt"), + same("question/data2.txt", "question/short_data/data2.txt"), + no_file("question/short_data/data10.txt").msg("V short_data/ je i data10.txt: '?' zastupuje právě jeden znak. Soubor odstraňte, nebo začněte znovu po 'shellgame reset'."), + ], + solution: r#" + bash -c 'cp data?.txt short_data/' + shellgame submit + "#, + success: "Správně! Otazník zastupuje právě jeden znak, proto se dvouciferný název do výběru nedostal.", + ..LEVEL + }, + Level { + title: "Výběr znaků []", + text: r#" + Hranaté závorky `[...]` nahradí JEDEN ze znaků uvnitř. + Například `[abc]` odpovídá znaku 'a', 'b' nebo 'c'. + + ### Požadavek: Bash + Tento level vyžaduje **Bash**. Ve fish použijte variantu s `bash -c` níže. + Ta spustí pouze daný příkaz v Bashi; odevzdávejte dál ve svém herním shellu. + + ### Příklady + - `zprava_[xy].txt` → zprava_x.txt, zprava_y.txt + - `log[123].txt` → log1.txt, log2.txt, log3.txt + + ### Úkol + Zkopírujte `file_a.txt` a `file_b.txt` do adresáře `ab_files/`. + NEKOPÍRUJTE `file_c.txt`. + + ### Příkazy + - V Bashi: `cp VZOR ab_files/` - VZOR sestavte sami z `file_`, závorek s povolenými znaky a `.txt` + - Z fish: `bash -c 'cp VZOR ab_files/'` - uvozovky ponechte + + Vzor nejdřív ověřte pomocí `ls VZOR` (z fish `bash -c 'ls VZOR'`): + vypsat se smějí jen soubory, které chcete zkopírovat. + + ### Odevzdání + Po splnění úkolu odevzdejte: `shellgame submit` + "#, + hints: &[ + "Hranaté závorky definují množinu povolených znaků na dané pozici.", + "[ab] znamená 'a nebo b', takže file_[ab].txt zachytí file_a.txt a file_b.txt.", + r#"V Bashi spusťte "cp file_[ab].txt ab_files/", z fish "bash -c 'cp file_[ab].txt ab_files/'"."#, + ], + start: "brackets", + setup: &[ + clean("brackets/ab_files"), + dir("brackets/ab_files"), + file("brackets/file_a.txt", ""), + file("brackets/file_b.txt", ""), + file("brackets/file_c.txt", ""), + ], + check: &[ + same("brackets/file_a.txt", "brackets/ab_files/file_a.txt"), + same("brackets/file_b.txt", "brackets/ab_files/file_b.txt"), + no_file("brackets/ab_files/file_c.txt").msg("V ab_files/ je i file_c.txt: závorky mají povolit jen 'a' a 'b'. Soubor odstraňte, nebo začněte znovu po 'shellgame reset'."), + ], + solution: r#" + bash -c 'cp file_[ab].txt ab_files/' + shellgame submit + "#, + success: "Správně! Závorky povolují na jedné pozici jen vyjmenované znaky, takže vzor vybral přesně požadovanou dvojici.", + ..LEVEL + }, + Level { + title: "Třídy znaků [[:lower:]]", + text: r#" + Rozsahy jako `[a-z]` mohou záviset na řazení nastaveného jazyka (locale), + například ve starších verzích Bashe (3.2 na macOS). + POSIX třída `[[:lower:]]` místo pořadí vybírá jeden znak klasifikovaný + jako malé písmeno. Pro tento úkol je proto spolehlivější napříč locale. + + Podobně existují `[[:upper:]]` pro velká písmena a `[[:digit:]]` pro číslice. + + ### Požadavek: Bash + Tento level vyžaduje **Bash**. Ve fish použijte variantu s `bash -c` níže. + Ta spustí pouze daný příkaz v Bashi; odevzdávejte dál ve svém herním shellu. + + ### Úkol + Zkopírujte všechny soubory `.txt` začínající malým písmenem do adresáře `lowercase/`. + NEKOPÍRUJTE soubory začínající velkým písmenem. + + ### Příkazy + - V Bashi: `cp VZOR lowercase/` - VZOR sestavte sami z třídy znaků, `*` a `.txt` + - Z fish: `bash -c 'cp VZOR lowercase/'` - uvozovky ponechte + + Vzor nejdřív ověřte pomocí `ls VZOR` (z fish `bash -c 'ls VZOR'`): + vypsat se smějí jen soubory, které chcete zkopírovat. + + Přípona `.txt` vyloučí cílový adresář `lowercase`, který také začíná malým písmenem. + + ### Odevzdání + Po splnění úkolu odevzdejte: `shellgame submit` + "#, + hints: &[ + "Třída [[:lower:]] vybere na dané pozici právě jedno malé písmeno bez závislosti na pořadí znaků v locale.", + "Vzor [[:lower:]]*.txt vybere názvy začínající malým písmenem a končící příponou .txt.", + r#"V Bashi spusťte "cp [[:lower:]]*.txt lowercase/", z fish "bash -c 'cp [[:lower:]]*.txt lowercase/'"."#, + ], + start: "ranges", + setup: &[ + clean("ranges/lowercase"), + dir("ranges/lowercase"), + file("ranges/apple.txt", ""), + file("ranges/Banana.txt", ""), + file("ranges/cherry.txt", ""), + file("ranges/Date.txt", ""), + ], + check: &[ + same("ranges/apple.txt", "ranges/lowercase/apple.txt"), + same("ranges/cherry.txt", "ranges/lowercase/cherry.txt"), + no_file("ranges/lowercase/Banana.txt").msg("V lowercase/ je i soubor začínající velkým písmenem. Odstraňte ho, nebo začněte znovu po 'shellgame reset'."), + no_file("ranges/lowercase/Date.txt").msg("V lowercase/ je i soubor začínající velkým písmenem. Odstraňte ho, nebo začněte znovu po 'shellgame reset'."), + ], + solution: r#" + bash -c 'cp [[:lower:]]*.txt lowercase/' + shellgame submit + "#, + success: "Správně! Třída znaků vybírá podle klasifikace písmene, takže výsledek nezávisí na řazení znaků v nastaveném locale.", + ..LEVEL + }, + Level { + title: "Souhrn Sekce 7", + text: r#" + ### Výzva: Tři druhy žolíků + + ### Úkol + V aktuálním adresáři použijte pro každý výběr jiný druh vzoru. + Než začnete kopírovat, předpovězte si odpovídající názvy a vzor ověřte pomocí `ls VZOR`. + + 1. Pomocí `*` zkopírujte všechny `.log` soubory do `logs/`. + 2. Pomocí `?` zkopírujte do `short_data/` jen soubory, jejichž název tvoří `data`, jeden znak a `.txt`. + 3. Pomocí množiny znaků `[...]` zkopírujte do `selected_reports/` reporty s písmenem `a` nebo `b`. + + Originály nechte na místě. Nakonec odevzdejte počty souborů v těchto třech cílových adresářích. + + `?` a `[...]` zde používají syntaxi Bashe. Ve fish spouštějte kopírování + i náhled `ls` přes `bash -c '...'`. + + ### Odevzdání + `shellgame submit <logy>,<data>,<reporty>` + "#, + hints: &[ + "Každý krok má procvičit jiný vzor. Nejdřív si pomocí 'ls' ověřte, že vzor nevybírá žádný z klamných souborů.", + "Logy potřebují žolík pro libovolný zbytek názvu, krátká data žolík pro právě jeden znak a reporty množinu se dvěma povolenými písmeny; každý sestavený vzor ověřte pomocí 'ls'.", + r#"Bash: "cp *.log logs/; cp data?.txt short_data/; cp report_[ab].csv selected_reports/". Fish: "bash -c 'cp *.log logs/; cp data?.txt short_data/; cp report_[ab].csv selected_reports/'"."#, + ], + start: "challenge", + setup: &[ + clean("challenge"), + dir("challenge/logs"), + dir("challenge/short_data"), + dir("challenge/selected_reports"), + file("challenge/app.log", "log1"), + file("challenge/error.log", "log2"), + file("challenge/debug.log", "log3"), + file("challenge/notes.txt", "txt"), + file("challenge/data1.txt", "short1"), + file("challenge/data2.txt", "short2"), + file("challenge/data10.txt", "long"), + file("challenge/report_a.csv", "a\n"), + file("challenge/report_b.csv", "b\n"), + file("challenge/report_c.csv", "c\n"), + file("challenge/script.sh", "#!/bin/bash\n"), + ], + answer: parts(",", &[ + int(3).error("Počet souborů v logs/ není správný. Spočítejte je ve výpisu 'ls logs'."), + int(2).error("Počet souborů v short_data/ není správný. Spočítejte je ve výpisu 'ls short_data'."), + int(2).error("Počet souborů v selected_reports/ není správný. Spočítejte je ve výpisu 'ls selected_reports'."), + ]) + .format("Formát: logy,data,reporty (tři čísla oddělená čárkou)"), + check: &[ + same("challenge/app.log", "challenge/logs/app.log"), + same("challenge/error.log", "challenge/logs/error.log"), + same("challenge/debug.log", "challenge/logs/debug.log"), + no_file("challenge/logs/notes.txt").msg("V logs/ je i soubor, který není log: vzor pro logy vybírá příliš mnoho. Ověřte ho pomocí 'ls' a začněte znovu po 'shellgame reset'."), + same("challenge/data1.txt", "challenge/short_data/data1.txt"), + same("challenge/data2.txt", "challenge/short_data/data2.txt"), + no_file("challenge/short_data/data10.txt").msg("V short_data/ je i data10.txt: '?' zastupuje právě jeden znak. Soubor odstraňte, nebo začněte znovu po 'shellgame reset'."), + same("challenge/report_a.csv", "challenge/selected_reports/report_a.csv"), + same("challenge/report_b.csv", "challenge/selected_reports/report_b.csv"), + no_file("challenge/selected_reports/report_c.csv").msg("V selected_reports/ je i report_c.csv: množina má povolit jen 'a' a 'b'. Soubor odstraňte, nebo začněte znovu po 'shellgame reset'."), + ], + solution: r#" + bash -c 'cp *.log logs/' + bash -c 'cp data?.txt short_data/' + bash -c 'cp report_[ab].csv selected_reports/' + shellgame submit "$(ls logs | wc -l),$(ls short_data | wc -l),$(ls selected_reports | wc -l)" + "#, + success: "Výborně! Dokončili jste Sekci 7: jeden vzor nahradí dlouhý seznam jmen a 'ls' vám předem ukáže, co vybere.", + ..LEVEL + }, + ], +}; diff --git a/src/levels/s08_permissions.rs b/src/levels/s08_permissions.rs new file mode 100644 index 0000000..a1eacd6 --- /dev/null +++ b/src/levels/s08_permissions.rs @@ -0,0 +1,429 @@ +//! Permissions: reading modes in `ls -l` and changing them with `chmod`, for files and directories. +//! +//! **Purpose.** Permissions explain most "Permission denied" messages. The section ties the +//! notation (rwx, symbolic and octal) to effects the student predicts and then observes, for +//! directories too. +//! +//! **Learning objectives.** After this section a student can: +//! - read the mode string of `ls -l` and find the file that its owner may run; +//! - add or remove permissions symbolically (`chmod u+x`, `a-w`, `g+r`) and set an exact mode in +//! octal (`chmod 755`, `644`); +//! - predict and verify that creating a file needs `w` on the directory, not on the file; +//! - predict and verify that `x` on a directory allows entering it (`chmod a-x ..` breaks `cd ..`). +//! +//! **Introduces:** `chmod` (symbolic and octal), `r`/`w`/`x` on files and on directories.\ +//! **Relies on:** [inspect](super::s05_inspect) (`ls -l`), [copy_move](super::s06_copy_move) +//! (`cp`).\ +//! **Leaves for later:** running scripts (shown in examples only; [streams](super::s10_streams) +//! runs prepared ones); not in the course: ownership, special bits, `umask` (named once). + +use crate::game::*; + +#[rustfmt::skip] +pub const SECTION: Section = Section { + title: "Oprávnění", + intro: r#" + V Linuxu má každý soubor a adresář nastavená oprávnění, která určují, kdo s ním může co dělat. + + ### Proč je to důležité? + + ### Bezpečnost systému + - **Hesla**: Soubor `/etc/shadow` obsahuje hesla - smí ho číst jen root! + - **Konfigurace**: Webový server nesmí měnit vlastní konfiguraci (jen číst) + - **Sdílení**: Spolužáci nevidí vaše soukromé soubory v domovském adresáři + + ### Běžné situace + ```bash + # "Permission denied" při spuštění skriptu? + $ ./muj_skript.sh + bash: ./muj_skript.sh: Permission denied + $ chmod u+x muj_skript.sh # Přidá spuštění pro vlastníka + $ ./muj_skript.sh + Hello World! + + # Webový server nevidí soubory? + $ chmod 644 index.html # Ostatní mohou číst + ``` + + ### Tři typy oprávnění + ``` + r (read) → Číst obsah souboru / vypsat obsah adresáře + w (write) → Měnit obsah souboru / vytvářet a mazat položky v adresáři + x (execute) → Spustit jako program / vstoupit do adresáře + ``` + + Smazání souboru závisí na právech `w` a `x` nadřazeného adresáře, + ne na právu `w` samotného souboru. + + --- + + ### Tři skupiny uživatelů + ``` + u (user) → Vlastník souboru (vy) + g (group) → Členové skupiny vlastníka + o (other) → Všichni ostatní + ``` + + ### Jak číst `ls -l` + ``` + -rwxr-xr-- = vlastník: rwx, skupina: r-x, ostatní: r-- + │└┬┘└┬┘└┬┘ + │ u g o + │ + └─ typ (- soubor, d adresář) + ``` + + ### Dva způsoby zápisu chmod + ``` + Symbolický: Číselný (oktalový): + chmod u+x soubor chmod 755 soubor + chmod g-w soubor + chmod o=r soubor r=4, w=2, x=1 + 755 = rwx|r-x|r-x + ``` + + ### Co se naučíte + - Číst oprávnění (`ls -l`) + - Měnit oprávnění (`chmod`) + - Používat symbolický zápis (`u+x`) i číselný zápis (`755`) + - Předvídat a prakticky ověřit, jak `w` a `x` ovlivňují práci s adresáři + "#, + levels: &[ + Level { + title: "Hledání spustitelného souboru", + text: r#" + Příkaz `ls -l` zobrazuje oprávnění v prvním sloupci (např. `-rw-r--r--`). + První trojice práv po znaku typu patří vlastníkovi souboru. + + ### Úkol + V adresáři je několik skriptů. Najděte jediný, který má právo spuštění (`x`) + nastavené pro vlastníka. `x` u skupiny nebo ostatních nestačí. + + ### Příkazy + - `ls -l`: Zobrazí oprávnění + + ### Odevzdání + Odevzdejte název spustitelného souboru. + `shellgame submit <soubor>` + "#, + hints: &[ + "Podrobný výpis souborů včetně sloupců s oprávněními získáte přepínačem '-l'.", + "Spusťte 'ls -l' a rozdělte práva po znaku typu na trojice pro vlastníka, skupinu a ostatní.", + "Hledejte 'x' v první trojici (pozice vlastníka), ne v trojici skupiny nebo ostatních.", + ], + start: "executables", + setup: &[ + file("executables/test.sh", "#!/bin/bash").mode(0o654), + file("executables/data.sh", "#!/bin/bash").mode(0o645), + file("executables/script.sh", r#" + #!/bin/bash + echo Hi + "#).mode(0o744), + ], + answer: suffix("script.sh") + .error("To není ten soubor. Ve výpisu 'ls -l' čtěte práva po znaku typu po trojicích: vlastník, skupina, ostatní. 'x' u skupiny nebo ostatních se nepočítá."), + solution: r#"shellgame submit "$(ls -l | grep '^-..x' | grep -o '[^ ]*$')""#, + success: "Správně! Práva se čtou po trojicích a rozhoduje ta, která odpovídá vaší roli.", + ..LEVEL + }, + Level { + title: "Nastavení spustitelnosti", + text: r#" + Aby šel skript spustit (např. `./script.sh`), musí mít nastavené právo `x`. + Příkaz `chmod` (change mode) mění oprávnění. + + ### Proč je to důležité? + Když napíšete skript nebo stáhnete program, často není automaticky spustitelný. + Musíte mu explicitně dát právo ke spuštění - je to bezpečnostní opatření. + + ### Běžný pracovní postup + 1. Napíšete skript: `nano muj_skript.sh` + 2. Pokusíte se spustit: `./muj_skript.sh` → "Permission denied" + 3. Přidáte právo: `chmod u+x muj_skript.sh` + 4. Nyní funguje: `./muj_skript.sh` ✓ + + ### Úkol + Soubor `run_me.sh` nejde spustit. Přidejte mu právo pro spuštění pro vlastníka (`u`). + + ### Příkazy + - `chmod u+x <soubor>`: Přidá právo spuštění (execute) vlastníkovi (u = user) + + ### Odevzdání + Po přidání práva spuštění spusťte: + `shellgame submit` + "#, + hints: &[ + "Právo 'x' (execute) je potřeba pro spuštění. Jak ho přidáte pro vlastníka (user)?", + "Syntaxe chmod: chmod kdo+co soubor. 'u' = user, 'x' = execute.", + "Použijte 'chmod u+x run_me.sh'.", + ], + start: "permissions", + setup: &[ + file("permissions/run_me.sh", r#" + #!/bin/bash + echo Run me + "#).mode(0o644), + ], + check: &[ + mode_has("permissions/run_me.sh", 0o100) + .msg("Vlastník stále nemá právo 'x'. Zkontrolujte 'ls -l run_me.sh': rozhoduje první trojice po znaku typu, ne trojice skupiny ani ostatních."), + ], + solution: r#" + chmod u+x run_me.sh + shellgame submit + "#, + success: "Správně! Symbolický chmod se skládá ze tří částí: kdo, operace a které právo.", + ..LEVEL + }, + Level { + title: "Odebrání práva zápisu", + text: r#" + Někdy chcete zabránit nechtěnému přepsání souboru. Můžete mu odebrat právo pro zápis (`w`). + + ### Úkol + Odeberte souboru `config.readonly` právo zápisu pro všechny: vlastníka, skupinu i ostatní. + + ### Příkazy + - `chmod a-w <soubor>`: Odebere právo zápisu (write) všem (a = all) + + Písmeno `a` nevynechávejte: bez něj výsledek ovlivňuje výchozí maska práv (`umask`). + + ### Odevzdání + Po odebrání všech práv zápisu spusťte: + `shellgame submit` + "#, + hints: &[ + "'a' znamená all (všechny: vlastníka, skupinu i ostatní), '-w' odebírá právo zápisu.", + "Tvar je 'chmod <kdo><operace><právo> <soubor>': tady je kdo = všichni, operace = odebrat a právo = zápis.", + "Spusťte 'chmod a-w config.readonly'.", + ], + start: "permissions", + setup: &[file("permissions/config.readonly", "Do not touch").mode(0o644)], + check: &[ + mode_lacks("permissions/config.readonly", 0o222) + .msg("Někde právo zápisu zůstalo. V 'ls -l config.readonly' projděte všechny tři trojice po řadě: 'w' nesmí být ani u vlastníka, ani u skupiny, ani u ostatních."), + ], + solution: r#" + chmod a-w config.readonly + shellgame submit + "#, + success: "Správně! Symbolický zápis mění právo jen vybraným trojicím a zbytek nechává být.", + ..LEVEL + }, + Level { + title: "Číselný zápis", + text: r#" + Oprávnění lze nastavit i číselně (oktalově). + - 4 = read (r) + - 2 = write (w) + - 1 = execute (x) + + Součet dává kombinaci (např. 7 = 4+2+1 = rwx, 5 = 4+1 = r-x). + Zadávají se tři čísla: pro vlastníka, skupinu a ostatní. + Např. `755` znamená `rwx` pro vlastníka, `r-x` pro skupinu, `r-x` pro ostatní. + + ### Úkol + Nastavte spouštěcímu skriptu `deploy.sh` oprávnění `755` (`rwxr-xr-x`). + + ### Příkazy + - `chmod 755 <soubor>` + + ### Odevzdání + Po nastavení přesného režimu spusťte: + `shellgame submit` + "#, + hints: &[ + "Oktalový zápis: 7 = 4+2+1 (rwx) pro vlastníka, 5 = 4+1 (r-x) pro skupinu a ostatní.", + "Tvar je 'chmod <tři číslice> <soubor>': každá číslice patří jedné trojici v pořadí vlastník, skupina, ostatní a je součtem 4 (r), 2 (w) a 1 (x).", + "Spusťte 'chmod 755 deploy.sh' a výsledek zkontrolujte pomocí 'ls -l deploy.sh'.", + ], + start: "permissions", + setup: &[ + file("permissions/deploy.sh", r#" + #!/bin/sh + echo 'Nasazuji aplikaci' + "#).mode(0o600), + ], + check: &[ + mode_is("permissions/deploy.sh", 0o755) + .msg("Režim zatím nesedí. Přečtěte si aktuální stav příkazem 'ls -l deploy.sh' a přeložte každou trojici zpět na číslici (r=4, w=2, x=1, '-'=0). Nejčastější chybou je právo zápisu navíc u skupiny nebo ostatních."), + ], + solution: r#" + chmod 755 deploy.sh + shellgame submit + "#, + success: "Správně! Číselný režim nastavuje všechny tři trojice najednou na přesnou hodnotu.", + ..LEVEL + }, + Level { + title: "Kontrolní úkol: Oprávnění souborů", + text: r#" + ### Kontrolní úkol: Oprávnění souborů + + Ukažte, že rozumíte oprávněním běžných souborů předtím, než přejdeme k adresářům! + + ### Úkol + V `level-8/challenge` jsou 4 soubory: + + 1. `script.sh` - potřebuje být **spustitelný** vlastníkem + 2. `secret.txt` - odeberte **právo zápisu všem** + 3. `shared.txt` - nastavte oprávnění **644** (rw-r--r--) + 4. `team.txt` - přidejte skupině právo čtení, ostatní práva neměňte + + ### Shrnutí příkazů Sekce 8 + ``` + ls -l → Zobrazí oprávnění + chmod u+x soubor → Přidá vlastníkovi právo spuštění + chmod a-w soubor → Odebere všem právo zápisu + chmod g+r soubor → Přidá skupině právo čtení + chmod 755 soubor → Nastaví rwxr-xr-x + chmod 644 soubor → Nastaví rw-r--r-- + ``` + + ### Oktalové oprávnění + ``` + 4 = read 2 = write 1 = execute + 7 = rwx 6 = rw- 5 = r-x 4 = r-- + ``` + + ### Odevzdání + Po splnění všech bodů spusťte `shellgame submit`. + "#, + hints: &[ + "U každého souboru určete skupinu uživatelů (u, g, o nebo a), operaci (+, - nebo =) a požadované právo.", + "První, druhý a čtvrtý úkol lze vyřešit symbolicky; u shared.txt nastavte přesný číselný režim.", + "Použijte 'chmod u+x script.sh', 'chmod a-w secret.txt', 'chmod 644 shared.txt' a 'chmod g+r team.txt'.", + ], + start: "challenge", + setup: &[ + clean("challenge"), + file("challenge/script.sh", r#" + #!/bin/bash + echo 'Hello' + "#).mode(0o600), + file("challenge/secret.txt", "Top secret!\n").mode(0o666), + file("challenge/shared.txt", "Shared content\n").mode(0o777), + file("challenge/team.txt", "Team notes\n").mode(0o600), + ], + check: &[ + mode_has("challenge/script.sh", 0o100).msg("Vlastník stále nemůže spustit script.sh."), + mode_lacks("challenge/secret.txt", 0o222).msg("secret.txt má stále alespoň jedno právo zápisu."), + mode_is("challenge/shared.txt", 0o644).msg("shared.txt nemá přesně oprávnění rw-r--r--."), + mode_is("challenge/team.txt", 0o640).msg("team.txt nemá právo čtení pro skupinu při zachování ostatních práv."), + ], + solution: r#" + chmod u+x script.sh + chmod a-w secret.txt + chmod 644 shared.txt + chmod g+r team.txt + shellgame submit + "#, + success: "Správně! Oprávnění běžných souborů máte pod kontrolou. Nyní se podíváme na adresáře.", + ..LEVEL + }, + Level { + title: "Kdo dovoluje zápis do adresáře?", + text: r#" + ### Cíl + Prakticky ověřte, že vytvoření nové položky řídí oprávnění **adresáře**, + ne oprávnění kopírovaného souboru. + + Adresář `locked` má režim `550`: lze ho číst a procházet, ale nelze do něj zapisovat. + Soubor `source.txt` má režim `444`: je jen pro čtení, nikdo do něj nesmí zapisovat. + + ### Předpověď a pozorování + **Nejdřív předpověď:** než cokoli spustíte, řekněte nahlas nebo si zapište, zda `cp source.txt locked/` + projde, nebo selže - a proč. Chybová hláška není překážka, ale hlavní výsledek pokusu; přečtěte si ji celou. + + 1. Spusťte `cp source.txt locked/` a porovnejte hlášku se svou předpovědí. + 2. Ověřte, že z adresáře `locked` lze kopírovat ven: `cp locked/report.txt recovered.txt`. + 3. Přidejte vlastníkovi adresáře právo zápisu: `chmod u+w locked`. + 4. Zopakujte `cp source.txt locked/`; tentokrát musí uspět, přestože `source.txt` je pořád jen pro čtení. + + ### Vysvětlení + Co rozhodovalo o možnosti vytvořit `locked/source.txt` — oprávnění + zdrojového **souboru**, nebo cílového **adresáře**? Odpověď si řekněte sami; + hra ověří provedené kroky. + + ### Odevzdání + Až bude kopie v `locked/`, spusťte `shellgame submit`. + "#, + hints: &[ + "Právo 'w' na soubor řídí změnu jeho obsahu. Vytvoření nového jména je operace nad adresářem.", + "Režim 550 nedává vlastníkovi adresáře právo 'w'; příkaz 'chmod u+w locked' ho přidá.", + "Spusťte 'cp locked/report.txt recovered.txt', 'chmod u+w locked', 'cp source.txt locked/' a potom 'shellgame submit'.", + ], + start: "directory-write", + setup: &[ + clean("directory-write"), + dir("directory-write/locked").mode(0o550), + file("directory-write/source.txt", "nová data\n").mode(0o444), + file("directory-write/locked/report.txt", "existující zpráva\n"), + ], + check: &[ + same("directory-write/locked/report.txt", "directory-write/recovered.txt") + .missing("Chybí recovered.txt: zkopírujte locked/report.txt ven pod jménem recovered.txt (krok 2)."), + same("directory-write/source.txt", "directory-write/locked/source.txt") + .missing("Po přidání práva zápisu zkopírujte source.txt do locked/."), + mode_has("directory-write/locked", 0o200).msg("Vlastník adresáře locked nemá právo zápisu. Přidejte ho příkazem 'chmod u+w locked'."), + ], + solution: r#" + cp locked/report.txt recovered.txt + chmod u+w locked + cp source.txt locked/ + shellgame submit + "#, + success: "Správně! Vytváření a mazání jmen řídí právo zápisu k nadřazenému adresáři.", + ..LEVEL + }, + Level { + title: "Právo x u adresáře", + text: r#" + ### Cíl + Zažijte rozdíl mezi čtením názvů a **průchodem** adresářem. + + Začínáte v `parent/child`. Nadřazený adresář má běžná práva `755`. + + ### Předpověď a pozorování + **Nejdřív předpověď:** po `chmod a-x ..` si zapište nebo řekněte nahlas, co udělá `cd ..` - a proč. + Hlášku, kterou shell vypíše, čtěte pozorně: právě ona je odpovědí, ne překážkou. + + 1. Příkazem `chmod a-x ..` odeberte všem právo průchodu nadřazeným adresářem. + 2. Zkuste `cd ..` a porovnejte výsledek se svou předpovědí. + 3. Příkazem `chmod u+x ..` vraťte průchod jen vlastníkovi. + 4. Zopakujte `cd ..`, nyní musí uspět. + + ### Vysvětlení + Které písmeno oprávnění umožňuje vstoupit do adresáře a procházet přes něj? + Odpověď si řekněte sami; hra ověří provedené kroky. + + ### Odevzdání + Až budete v adresáři `parent`, spusťte `shellgame submit`. + "#, + hints: &[ + "U adresáře neznamená 'x' spuštění programu, ale možnost průchodu cestou.", + "Po 'chmod a-x ..' nadřazený adresář stále může mít 'r', ale cesta přes něj nefunguje.", + "Obnovte vlastníkovi průchod příkazem 'chmod u+x ..', přejděte o úroveň výš pomocí 'cd ..' a spusťte 'shellgame submit'.", + ], + start: "directory-traverse/parent/child", + setup: &[ + clean("directory-traverse"), + dir("directory-traverse/parent").mode(0o755), + dir("directory-traverse/parent/child").mode(0o755), + ], + check: &[ + at("directory-traverse/parent"), + mode_has("directory-traverse/parent", 0o100).msg("Adresář parent, ve kterém stojíte, nemá právo 'x' pro vlastníka. Přidejte mu ho příkazem 'chmod u+x .'."), + mode_lacks("directory-traverse/parent", 0o011).msg("Právo 'x' k adresáři parent, ve kterém stojíte, má mít jen vlastník. Skupině a ostatním ho odeberte příkazem 'chmod go-x .', nebo začněte znovu po 'shellgame reset'."), + ], + solution: r#" + chmod a-x .. + chmod u+x .. + cd .. + shellgame submit + "#, + success: "Výborně! Dokončili jste Sekci 8 a rozumíte právům souborů i adresářů.", + ..LEVEL + }, + ], +}; diff --git a/src/levels/s09_redirection.rs b/src/levels/s09_redirection.rs new file mode 100644 index 0000000..afec191 --- /dev/null +++ b/src/levels/s09_redirection.rs @@ -0,0 +1,563 @@ +//! Input, output and exit status: `>`, `>>`, pipes, quoting, keyboard input, `&&` and `||`. +//! +//! **Purpose.** Commands become building blocks: output is saved, appended or passed on, input can +//! come from the keyboard, and success or failure decides what runs next. It is also where the +//! small filters that pipelines are made of first appear. +//! +//! **Learning objectives.** After this section a student can: +//! - save output with `>` (overwrites) or `>>` (keeps content), and join files with `cat a b > c`; +//! - quote text and file names that contain spaces; +//! - use `head -n`, `tail -n` and `wc` on files, and build pipelines (`sort | uniq | wc -l`); +//! - type a file in with `cat > file`, end it with Ctrl+D (EOF), and know that Ctrl+C interrupts +//! instead (lines already sent with Enter stay; the game cannot tell which one ended the input); +//! - use the exit status: `&&` continues only after success, `||` only after failure. +//! +//! **Introduces:** `>`, `>>`, `|`, `echo` for text, `head`, `tail`, `wc`, `sort`, `uniq`, `true`, +//! `false`, `&&`, `||`, quoting, EOF.\ +//! **Relies on:** [navigation](super::s01_navigation) (`ls`), [files](super::s02_files) (`cat`) +//! and, ahead of [search](super::s11_search), `grep "pattern" file` as a ready-made filter.\ +//! **Leaves for later:** error output (`2>`, `&>`, `/dev/null`: [streams](super::s10_streams)), +//! searching itself (`grep` options, `find`: [search](super::s11_search)). + +use crate::game::*; + +#[rustfmt::skip] +pub const SECTION: Section = Section { + title: "Vstup, výstup a stav příkazů", + intro: r#" + Příkaz obvykle posílá výstup na obrazovku. Tři operátory rozhodnou, kam poteče dál: + + ```text + příkaz > soubor výstup přepíše soubor + příkaz >> soubor výstup se přidá na konec + příkaz | další výstup se stane vstupem dalšího příkazu + ``` + + ### Tři mentální modely + + - `>` je **nový zápis**. Starý obsah cílového souboru zmizí. + - `>>` je **připojení**. Dosavadní obsah zůstane. + - `|` je **roura mezi příkazy**. Nevytváří soubor, jen předává data dál. + + ```bash + ls > seznam.txt + echo "další řádek" >> seznam.txt + grep ERROR access.log | wc -l + ``` + + Má-li text nebo název souboru mezery, uzavřete každou takovou část zvlášť: + + ```bash + echo "Ahoj svete" > "muj pozdrav.txt" + ``` + + --- + + # Klávesnicový vstup a stav příkazu + + `cat > soubor` čte řádky z klávesnice. **Ctrl+D** oznámí EOF, tedy konec vstupu, + a `cat` řádně skončí. **Ctrl+C** je interrupt: běžící příkaz přeruší. + + Každý příkaz také vrací stavový neboli návratový kód: + + - `0` znamená úspěch, + - nenulový kód znamená neúspěch, + - `první && druhý` pokračuje jen po úspěchu, + - `první || druhý` pokračuje jen po neúspěchu. + + `&&` a `||` fungují stejně v Bashi i ve fish. + + ### Co se naučíte + + - ukládat a přidávat výstup pomocí `>` a `>>` + - správně citovat víceslovný text i názvy souborů + - zapisovat interaktivní vstup přes `cat` a ukončit jej pomocí EOF + - propojovat příkazy pomocí `|` + - filtrovat a zpracovávat text přes `grep`, `head`, `tail`, `wc`, `sort` a `uniq` + - reagovat na úspěch či neúspěch příkazu pomocí `&&` a `||` + "#, + levels: &[ + Level { + title: "Uložení výstupu", + text: r#" + Operátor `>` přesměruje výstup příkazu do souboru. Pokud soubor neexistuje, vytvoří se. + Pokud existuje, **přepíše se**. + + ### Úkol + Uložte seznam souborů v aktuálním adresáři (výstup `ls`) do souboru `seznam.txt`. + + ### Příkazy + - `ls > seznam.txt` + + ### Odevzdání + Po vytvoření souboru spusťte: + `shellgame submit` + "#, + hints: &[ + "Použijte operátor '>' pro přesměrování výstupu.", + "Příkaz 'ls' vypíše obsah adresáře.", + "Zkuste: 'ls > seznam.txt'.", + ], + start: "redirection", + setup: &[clean("redirection/seznam.txt"), file("redirection/file1", ""), file("redirection/file2", "")], + check: &[ + contains("redirection/seznam.txt", &["file1", "file2"]) + .msg("Soubor neobsahuje očekávaný výstup příkazu ls.") + .missing("Soubor seznam.txt zatím neexistuje."), + ], + solution: r#" + ls > seznam.txt + shellgame submit + "#, + success: "Správně! Výstup příkazu nemusí skončit na obrazovce — dá se uložit a dál s ním pracovat.", + ..LEVEL + }, + Level { + title: "Přidání na konec", + text: r#" + Operátor `>>` (append) přidá výstup na konec souboru, aniž by smazal původní obsah. + + ### Úkol + Máte soubor `log.txt` s nějakým obsahem. Přidejte na jeho konec text "Konec logu" + pomocí příkazu `echo`. + + ### Příkazy + - `echo "Text" >> soubor` + + ### Odevzdání + Po přidání textu spusťte: + `shellgame submit` + "#, + hints: &[ + "Dvě šipky '>>' znamenají append (připojení na konec souboru bez přepsání obsahu).", + r#"Spusťte 'echo "Konec logu" >> log.txt'."#, + ], + start: "redirection", + setup: &[ + file("redirection/log.txt", r#" + Start logu + Zaznam 1 + "#), + ], + check: &[ + content_is("redirection/log.txt", r#" + Start logu + Zaznam 1 + Konec logu + "#) + .msg("Soubor musí zachovat původní obsah a přidat nový řádek přesně na konec. Zmizely-li původní řádky, použili jste jednoduchou šipku '>', která soubor přepíše; připojení zajistí až zdvojená šipka. Původní stav vrátí 'shellgame reset'.") + .missing("Soubor log.txt chybí. Původní stav vrátí 'shellgame reset'."), + ], + solution: r#" + echo 'Konec logu' >> log.txt + shellgame submit + "#, + success: "Správně! Zdvojená šipka připojuje, takže předchozí obsah souboru zůstane zachovaný.", + ..LEVEL + }, + Level { + title: "Spojování souborů", + text: r#" + Příkaz `cat` (concatenate) umí vypsat obsah více souborů za sebou. + Když to zkombinujete s přesměrováním, můžete spojit více souborů do jednoho. + + ### Úkol + Spojte obsah souborů `part1.txt` a `part2.txt` do nového souboru `full.txt`. + + ### Příkazy + - `cat soubor1 soubor2 > novy_soubor` + + ### Odevzdání + Po vytvoření spojeného souboru spusťte: + `shellgame submit` + "#, + hints: &[ + "Příkaz 'cat' umí přijmout více souborů najednou a vypsat jejich obsahy za sebou.", + "Výstup více souborů z 'cat' můžete přesměrovat pomocí '>' do cílového souboru.", + "Spusťte 'cat part1.txt part2.txt > full.txt'. Pořadí argumentů určuje pořadí v souboru.", + ], + start: "concat", + setup: &[ + clean("concat/full.txt"), + file("concat/part1.txt", "First part.\n"), + file("concat/part2.txt", "Second part.\n"), + ], + check: &[ + content_is("concat/full.txt", r#" + First part. + Second part. + "#) + .msg("Soubor musí obsahovat obě části přesně v zadaném pořadí. Jsou-li obsahy na místě, ale prohozené, prohodili jste argumenty příkazu cat: jejich pořadí určuje pořadí řádků ve výstupu.") + .missing("Soubor full.txt zatím neexistuje."), + ], + solution: r#" + cat part1.txt part2.txt > full.txt + shellgame submit + "#, + success: "Správně! Příkaz cat čte soubory v pořadí argumentů a stejné pořadí má i výsledek.", + ..LEVEL + }, + Level { + title: "Uvozovky v textu i názvu", + text: r#" + Shell dělí příkaz podle mezer. Uvozovky proto chrání víceslovný text i název + souboru s mezerami — každou část uzavřete zvlášť. + + ### Úkol + Pomocí `echo` vytvořte soubor `muj pozdrav.txt` s jediným řádkem `Ahoj svete`. + + ### Příkaz + - `echo "Ahoj svete" > "muj pozdrav.txt"` + + První dvojice uvozovek chrání text, druhá název výstupního souboru. + Bez nich by shell vytvořil soubor `muj` a slovo `pozdrav.txt` by poslal do `echo`. + + ### Odevzdání + Po vytvoření souboru spusťte: + `shellgame submit` + "#, + hints: &[ + "Mezery oddělují argumenty. Uvozovky udrží více slov pohromadě jako jeden text nebo jednu cestu.", + "Uzavřete do uvozovek text za 'echo' a zvlášť také název za '>'.", + r#"Spusťte echo "Ahoj svete" > "muj pozdrav.txt" a potom 'shellgame submit'."#, + ], + start: "echo", + setup: &[clean("echo/pozdrav.txt"), clean("echo/muj"), clean("echo/muj pozdrav.txt"), dir("echo")], + check: &[ + content_is("echo/muj pozdrav.txt", "Ahoj svete\n") + .msg("Soubor 'muj pozdrav.txt' má obsahovat jediný řádek 'Ahoj svete' zakončený novým řádkem, přesně tak, jak ho zapíše echo.") + .missing("Soubor 'muj pozdrav.txt' zatím neexistuje. Bez uvozovek kolem názvu vznikne jen soubor 'muj'."), + ], + solution: r#" + echo "Ahoj svete" > "muj pozdrav.txt" + shellgame submit + "#, + success: "Správně! Uvozovky drží pohromadě to, co by shell jinak rozdělil podle mezer.", + ..LEVEL + }, + Level { + title: "Propojení příkazů (Pipes)", + text: r#" + Znak `|` (pipe/roura) pošle výstup jednoho příkazu jako vstup druhému. + + ### Stavební kameny + - `grep "vzor" soubor` vypíše jen ty řádky souboru, které vzor obsahují. + Podrobně se `grep` probírá v sekci „Vyhledávání“; tady ho použijeme jako hotový filtr. + - `wc -l` spočítá řádky, které dostane na vstupu. + - `|` pošle výstup levého příkazu na vstup pravého. + + ### Úkol + V aktuálním adresáři je soubor `access.log` s mnoha řádky. + Spočítejte, kolik řádků obsahuje slovo "ERROR". Příkaz si složte sami ze tří dílů výše. + + ### Odevzdání + Odevzdejte nalezený počet (číslo). + `shellgame submit <číslo>` + "#, + hints: &[ + "Pipe (|) propojuje výstup prvního příkazu se vstupem druhého.", + "grep najde řádky s 'ERROR', wc -l je spočítá. Spojte je pomocí |.", + r#"Použijte: grep "ERROR" access.log | wc -l"#, + ], + start: "pipes", + setup: &[ + file("pipes/access.log", r#" + 2024-01-01 10:00:00 INFO Server started + 2024-01-01 10:05:23 ERROR Connection refused + 2024-01-01 10:10:45 INFO User logged in + 2024-01-01 10:15:00 WARNING Low memory + 2024-01-01 10:20:12 ERROR Database timeout + 2024-01-01 10:25:00 INFO Request processed + 2024-01-01 10:30:33 ERROR File not found + 2024-01-01 10:35:00 INFO Cache cleared + 2024-01-01 10:40:55 ERROR Permission denied + 2024-01-01 10:45:00 DEBUG Verbose output + 2024-01-01 10:50:18 ERROR Network unreachable + 2024-01-01 10:55:00 INFO Backup completed + 2024-01-01 11:00:00 ERROR Disk full + 2024-01-01 11:05:00 INFO Server shutdown + 2024-01-01 11:10:42 ERROR Service unavailable + "#), + ], + answer: int(7).mistakes(&[("15", "Spočítali jste všechny řádky. Potřebujete jen ty s 'ERROR'. Použijte grep před wc.")]), + solution: r#"shellgame submit "$(grep ERROR access.log | wc -l)""#, + success: "Správně! Roura spojí jednoduché příkazy v nástroj, jakým žádný z nich sám o sobě není.", + ..LEVEL + }, + Level { + title: "Začátek a konec souboru", + text: r#" + Příkazy `head` a `tail` ukážou jen začátek nebo konec souboru. + + ### Úkol + V souboru `long_file.txt` je 50 řádků. + 1. Zjistěte první slovo na 1. řádku (pomocí `head -n 1`) + 2. Zjistěte první slovo na posledním řádku (pomocí `tail -n 1`) + + ### Odevzdání + Odevzdejte obě slova oddělená čárkou, nejdřív slovo z prvního řádku: + `shellgame submit <slovo1>,<slovo2>` + "#, + hints: &[ + "head -n 1 zobrazí první řádek, tail -n 1 zobrazí poslední.", + "Odevzdejte první slovo z každého z těchto dvou řádků.", + "Formát odpovědi je 'slovo1,slovo2' - dvě slova oddělená čárkou, bez mezery.", + ], + start: "headtail", + setup: &[ + build(|root| { + let text = long_file("Pelican flew over the dam - this is line 1", "Walrus slept on the pier - this is line 50"); + write(root, "headtail/long_file.txt", text) + }), + ], + answer: parts(",", &[ + exact("Pelican").nocase().error("Slovo z prvního řádku není správné. Použijte 'head -n 1 long_file.txt'."), + exact("Walrus").nocase().error("Slovo z posledního řádku není správné. Použijte 'tail -n 1 long_file.txt'."), + ]) + .format("Formát: slovo_z_prvního_řádku,slovo_z_posledního_řádku (např. AHOJ,SVET)"), + solution: r#"shellgame submit "$(head -n 1 long_file.txt | cut -d' ' -f1),$(tail -n 1 long_file.txt | cut -d' ' -f1)""#, + success: "Správně! Head a tail jsou skvělé pro rychlý náhled do souborů.", + ..LEVEL + }, + Level { + title: "Počítání (wc)", + text: r#" + Příkaz `wc` (word count) počítá řádky, slova a znaky. + + ### Úkol + Zjistěte o souboru `article.txt`: + 1. Kolik má řádků? (`wc -l`) + 2. Kolik má slov? (`wc -w`) + + ### Odevzdání + Odevzdejte: `řádky,slova` (např. `10,50`) + `shellgame submit <řádky>,<slova>` + "#, + hints: &[ + "wc -l počítá řádky, wc -w počítá slova.", + "Spusťte oba příkazy na 'article.txt' a zapište si obě čísla.", + "Odevzdejte je v pořadí řádky,slova — bez mezery za čárkou.", + ], + start: "wc", + setup: &[ + file("wc/article.txt", r#" + Linux je svobodný operační systém. + Byl vytvořen Linusem Torvaldsem v roce 1991. + Dnes pohání většinu serverů na internetu. + Je základem systému Android a mnoha dalších. + Open source komunita ho neustále vylepšuje. + "#), + ], + answer: parts(",", &[ + int(5) + .mistakes(&[ + ("31", "První číslo má být počet řádků, ne slov — vypadá to, že máte hodnoty prohozené. Pořadí je řádky,slova."), + ]) + .error("Počet řádků není správný. Použijte 'wc -l article.txt'."), + int(31) + .mistakes(&[ + ("5", "Druhé číslo má být počet slov, ne řádků. Ten už jste zapsali jako první hodnotu."), + ]) + .error("Počet slov není správný. Použijte 'wc -w article.txt'."), + ]) + .format("Formát: řádky,slova (např. 10,50) — na pořadí obou čísel záleží."), + solution: r#"shellgame submit "$(wc -l < article.txt),$(wc -w < article.txt)""#, + success: "Správně! Příkaz wc je nepostradatelný pro rychlou analýzu souborů.", + ..LEVEL + }, + Level { + title: "Řazení a odstranění duplicit", + text: r#" + Příkazy `sort` a `uniq` jsou mocné nástroje pro zpracování textových dat. + + ### Stavební kameny + - `sort soubor` vypíše řádky seřazeně, takže stejné hodnoty skončí vedle sebe. + - `uniq` zahodí opakující se řádky, ale pozná jen ty **sousední**. + - `wc -l` spočítá řádky, které dostane na vstupu. + + Pořadí proto není libovolné: rozmyslete si, co musí `uniq` dostat na vstup, aby fungoval. + + ### Úkol + V souboru `visitors.txt` jsou jména návštěvníků (někteří přišli vícekrát). + Zjistěte, kolik je UNIKÁTNÍCH návštěvníků. Příkazy si pospojujte rourami sami. + + ### Odevzdání + Odevzdejte počet unikátních návštěvníků. + `shellgame submit <číslo>` + "#, + hints: &[ + "Příkaz uniq odstraní duplikáty, ale jen sousedící! Proto nejdřív sort.", + "Řetězec: sort → uniq → wc -l spočítá unikátní řádky.", + "Spusťte 'sort visitors.txt | uniq | wc -l' a odevzdejte číslo z výstupu.", + ], + start: "sort", + setup: &[ + file("sort/visitors.txt", r#" + Alice + Bob + Charlie + Alice + David + Bob + Eve + Alice + "#), + ], + answer: int(5) + .mistakes(&[ + ("8", "Spočítali jste všechny řádky. Bez předchozího sort nenajde uniq žádné duplicity, protože stejná jména nejsou vedle sebe."), + ("2", "Spočítali jste jen jména, která se opakují (to dělá uniq -d). Hledáme počet všech různých jmen."), + ("3", "Spočítali jste jen jména, která přišla jedinkrát (to dělá uniq -u). Hledáme počet všech různých jmen."), + ]), + solution: r#"shellgame submit "$(sort visitors.txt | uniq | wc -l)""#, + success: "Správně! Sort | uniq je klasická kombinace pro práci s daty.", + ..LEVEL + }, + Level { + title: "Výzva: Přesměrování a roury", + text: r#" + ### Úkol + V aktuálním adresáři: + + 1. Vytvořte `message.txt` s prvním řádkem `Hello World`. + 2. Přidejte na konec druhý řádek `Goodbye`, aniž by první zmizel. + 3. Propojte výpis obsahu adresáře s počítáním řádků a zjistěte počet položek. + + Vystačíte si s `echo`, `>`, `>>`, `ls`, `|` a `wc -l`. + Odevzdejte zjištěný počet položek. + + ### Odevzdání + `shellgame submit <počet>` + "#, + hints: &[ + "První přesměrování má soubor vytvořit, druhé musí zachovat jeho obsah. Výpis pak pošlete rourou do počítadla řádků.", + r#"Soubor vytvoříte pomocí echo "Hello World" > message.txt a druhý řádek přidáte přes '>>'."#, + "Počet položek zjistíte příkazem 'ls | wc -l'.", + ], + start: "challenge", + setup: &[clean("challenge"), file("challenge/sample1.txt", "sample"), file("challenge/sample2.txt", "sample")], + answer: int(3).mistakes(&[("2", "Spočítali jste jen sample1.txt a sample2.txt. Vytvořili jste message.txt?")]), + check: &[ + content_is("challenge/message.txt", r#" + Hello World + Goodbye + "#) + .msg("message.txt má mít přesně dva řádky, 'Hello World' a pod ním 'Goodbye', každý zakončený novým řádkem (tak je zapíše echo) a nic dalšího. Chybí-li první řádek, přepsalo ho druhé přesměrování '>'.") + .missing(r#"Chybí message.txt. Vytvořte ho pomocí echo "Hello World" > message.txt."#), + ], + solution: r#" + echo "Hello World" > message.txt + echo "Goodbye" >> message.txt + shellgame submit "$(ls | wc -l)" + "#, + success: "Skvělé! Přesměrování i roury máte v malíku!", + ..LEVEL + }, + Level { + title: "Interaktivní vstup a EOF", + text: r#" + Když spustíte `cat > soubor`, příkaz čte řádky z klávesnice a zapisuje je do souboru. + Na prázdném řádku stiskněte **Ctrl+D**: terminál tím oznámí EOF (konec vstupu) a `cat` + řádně skončí. + + **Ctrl+C** místo toho běžící příkaz přeruší (interrupt). Řádky, které už jste odeslali + Enterem, `cat` zapsal hned, takže v souboru zůstanou; ztratí se jen rozepsaný řádek. + Výsledný soubor tak může vypadat stejně – hra proto nepozná, čím jste vstup ukončili. + Rozdíl je v tom, že Ctrl+D je řádný konec vstupu, kdežto Ctrl+C příkaz násilně ukončí; + program, který zapisuje až na konci (třeba `sort > soubor`), by po Ctrl+C nezapsal nic. + + ### Úkol + Spusťte `cat > poznamka.txt` a zadejte přesně tyto dva řádky: + + ```text + Koupit chleba + Zavolat mamince + ``` + + Po druhém řádku stiskněte Enter a potom na prázdném řádku Ctrl+D. + + ### Odevzdání + Po ukončení zápisu pomocí Ctrl+D spusťte: + `shellgame submit` + "#, + hints: &[ + "'cat' bez názvu vstupního souboru čte standardní vstup; EOF mu oznámí, že už žádná data nepřijdou.", + "Po 'cat > poznamka.txt' napište oba řádky, každý ukončete Enterem. Ctrl+D použijte až na novém prázdném řádku.", + "Při překlepu spusťte znovu 'cat > poznamka.txt' ('>' soubor přepíše) a zápis zopakujte.", + ], + start: "stdin", + setup: &[clean("stdin/poznamka.txt"), dir("stdin")], + check: &[ + content_is("stdin/poznamka.txt", r#" + Koupit chleba + Zavolat mamince + "#) + .msg("Obsah musí přesně odpovídat zadání: řádek 'Koupit chleba', pod ním 'Zavolat mamince', každý ukončený Enterem, a nic dalšího. Zkontrolujte překlepy a mezery; znovu začnete příkazem 'cat > poznamka.txt', který soubor přepíše.") + .missing("Soubor poznamka.txt chybí. Začněte příkazem 'cat > poznamka.txt'."), + ], + solution: r#" + printf '%s\n' 'Koupit chleba' 'Zavolat mamince' | cat > poznamka.txt + shellgame submit + "#, + success: "Správně! Soubor obsahuje oba řádky. Ctrl+D vstup řádně ukončí, Ctrl+C příkaz přeruší.", + ..LEVEL + }, + Level { + title: "Návratový kód: && a ||", + text: r#" + Každý příkaz skončí návratovým kódem (exit code): **0 znamená úspěch**, nenulová + hodnota neúspěch. V Bashi i ve fish podle něj můžete spojovat příkazy: + + - `první && druhý` spustí druhý jen po úspěchu prvního, + - `první || druhý` spustí druhý jen po neúspěchu prvního. + + Příkaz `true` vrací stav 0 (úspěch), `false` nenulový stav (neúspěch). + + ### Úkol + Spusťte postupně oba řetězce; zapíšou do souboru `status.txt` dva řádky: + + ```bash + true && echo "stav: uspech" ___ status.txt + false || echo "stav: neuspech" ___ status.txt + ``` + + Na místo `___` doplňte přesměrování `>` nebo `>>`. Rozhodněte se podle toho, + že první řádek soubor zakládá a druhý se musí přidat za něj — výsledek má mít + právě dva řádky v tomto pořadí. + + ### Odevzdání + Až bude soubor hotový, spusťte: + `shellgame submit` + "#, + hints: &[ + "Návratový kód 0 značí úspěch, nenulový kód neúspěch. Operátory sledují právě tento stav.", + "Za '&&' pokračuje úspěšný příkaz; za '||' pokračuje neúspěšný. První řetězec soubor zakládá (stačí přepsání), druhý k němu jen přidává.", + r#"Spusťte true && echo "stav: uspech" > status.txt a potom false || echo "stav: neuspech" >> status.txt."#, + ], + start: "status", + setup: &[clean("status/status.txt"), dir("status")], + check: &[ + content_is("status/status.txt", r#" + stav: uspech + stav: neuspech + "#) + .msg("Soubor musí obsahovat přesně oba řádky ve správném pořadí. Zbyl-li jen jeden řádek, přepsalo druhé přesměrování to první; přebývá-li řádek, první příkaz soubor nepřepsal: použijte u něj '>'.") + .missing("Soubor ještě neexistuje. Spusťte oba zadané řetězce příkazů."), + ], + solution: r#" + true && echo "stav: uspech" > status.txt + false || echo "stav: neuspech" >> status.txt + shellgame submit + "#, + success: "Výborně! Dokončili jste Sekci 9 a umíte reagovat na stav příkazu.", + ..LEVEL + }, + ], +}; + +/// 50 lines: `first`, 48 lines of filler, `last`. +fn long_file(first: &str, last: &str) -> String { + let middle: Vec<String> = + (2..50).map(|n| format!("Line number {n} with some content")).collect(); + format!("{first}\n{}\n{last}\n", middle.join("\n")) +} diff --git a/src/levels/s10_streams.rs b/src/levels/s10_streams.rs new file mode 100644 index 0000000..fbc33f9 --- /dev/null +++ b/src/levels/s10_streams.rs @@ -0,0 +1,322 @@ +//! Error output: stdout and stderr as separate streams, steered with `2>`, `&>` and `/dev/null`. +//! +//! **Purpose.** A command's complaints do not travel with its results. The section separates the +//! two streams: log errors, keep both, silence a noisy command, or split them in one run. +//! +//! **Learning objectives.** After this section a student can: +//! - name the two output streams (stdout, fd 1; stderr, fd 2) and say which operator touches which +//! (`>` stdout only, `2>` stderr only); +//! - save only the errors with `2>` and append them with `2>>` without losing earlier lines; +//! - send both streams to one file with `&>`, or discard everything with `&> /dev/null`; +//! - split the streams of one run into two files with one command (`> out 2> err`) and count lines. +//! +//! **Introduces:** stdout and stderr (fd 1 and 2), `2>`, `2>>`, `&>`, `/dev/null`, running a script +//! as `./name` (or `bash name` where /tmp does not allow running programs).\ +//! **Relies on:** [redirection](super::s09_redirection) (`>`, `>>`, `wc -l`); the scripts come +//! ready to run, as shown in [permissions](super::s08_permissions).\ +//! **Leaves for later:** not in the course: `2>&1`, streams inside pipelines, `tee`. + +use crate::game::*; + +#[rustfmt::skip] +pub const SECTION: Section = Section { + title: "Chybové výstupy", + intro: r#" + Každý příkaz v Linuxu má dva výstupní kanály: + 1. **stdout** (fd 1) - standardní výstup pro normální výsledky + 2. **stderr** (fd 2) - chybový výstup pro chybové zprávy + + ### Dva proudy výstupu + ``` + ┌─────────┐ + │ příkaz │──── stdout (1) ───▶ Normální výstup + │ │──── stderr (2) ───▶ Chybové zprávy + └─────────┘ + ``` + + ### Operátory přesměrování + ``` + > Přesměruje stdout (normální výstup) + 2> Přesměruje stderr (chyby) + &> Přesměruje OBOJÍ (stdout + stderr) + >> Přidá stdout na konec souboru + 2>> Přidá stderr na konec souboru + ``` + + ### Příklad + ```bash + ./skript.sh # Obojí na obrazovku + ./skript.sh > out.log # stdout do souboru, stderr na obrazovku + ./skript.sh 2> err.log # stderr do souboru, stdout na obrazovku + ./skript.sh &> all.log # Všechno do souboru + ./skript.sh &> /dev/null # Zahodí všechno (ticho) + ``` + + ### Co se naučíte + - Standardní chybový výstup (stderr) + - Přesměrování chybových zpráv (`2>`) + - Přesměrování všeho (`&>`) + - Zahazování výstupu (`/dev/null`) + "#, + levels: &[ + Level { + title: "Přesměrování chyb", + text: r#" + Standardní chybový výstup (stderr) používá deskriptor souboru 2. + Pro přesměrování pouze chyb použijte `2>`. + + ### Proč je to důležité + Při běhu programů často chcete zachytit chybové hlášky do logu, + zatímco normální výstup zobrazíte uživateli. Oddělení stdout a stderr + je klíčové pro diagnostiku problémů. + + ### Jak spustit skript + `./buggy.sh` spustí soubor `buggy.sh` z aktuálního adresáře. Předpona `./` říká shellu, + kde soubor leží; bez ní by ho hledal jen v adresářích s programy (proměnná `PATH`). + Skript potřebuje právo `x`, které už má. Kdyby přesto hlásil „Permission denied“ + (některé systémy nedovolují spouštět programy z /tmp), spusťte ho jako `bash buggy.sh`. + + ### Úkol + V adresáři je skript `buggy.sh`, který vypisuje normální text i chybové zprávy. + Spusťte ho a přesměrujte POUZE chybové zprávy do souboru `errors.log`. + + ### Příkazy + - `./script 2> soubor` - přesměruje stderr do souboru + + ### Odevzdání + Po vytvoření souboru spusťte: + `shellgame submit` + "#, + hints: &[ + "Běžný výstup jde na stdout (1), chyby na stderr (2). Jak přesměrujete jen dvojku?", + "Syntaxe je: příkaz 2> soubor. Zkuste to se skriptem buggy.sh.", + "Použijte './buggy.sh 2> errors.log' a potom 'shellgame submit'.", + ], + setup: &[ + clean("errors.log"), + file("buggy.sh", r#" + #!/bin/bash + echo 'This is normal output' + echo 'This is an error message' >&2 + "#).mode(0o755), + ], + check: &[ + content_is("errors.log", "This is an error message\n") + .msg("Soubor má obsahovat jen chybovou hlášku skriptu, jednou a bez normálního výstupu (použili jste &> nebo chybí 2?).") + .missing("Soubor errors.log zatím neexistuje."), + ], + solution: r#" + ./buggy.sh 2> errors.log + shellgame submit + "#, + success: "Správně! Soubor obsahuje pouze chyby.", + ..LEVEL + }, + Level { + title: "Přidávání chyb", + text: r#" + Stejně jako u normálního výstupu se i u chyb rozhodujete mezi dvěma operátory: + jeden soubor před zápisem vyprázdní, druhý zapisuje na jeho konec. + + ### Úkol + V souboru `errors.log` už je z minula záznam `Old error 1`. + Spusťte `buggy.sh` znovu tak, aby v souboru zůstal starý záznam i nová chyba. + + Než příkaz spustíte, rozmyslete si: který z obou operátorů soubor nejdřív + vyprázdní a co by se v takovém případě stalo s řádkem `Old error 1`? + + ### Příkazy + - `./script 2> soubor` - chybový výstup přepíše obsah souboru + - `./script 2>> soubor` - chybový výstup přidá na konec souboru + + ### Odevzdání + Po spuštění skriptu spusťte: + `shellgame submit` + "#, + hints: &[ + "Jaký je rozdíl mezi > a >>? Jeden přepisuje, druhý přidává.", + "Pro přidání chyb na konec použijte dvě šipky: 2>>", + "Použijte './buggy.sh 2>> errors.log' a potom 'shellgame submit'.", + ], + setup: &[ + file("buggy.sh", r#" + #!/bin/bash + echo 'This is normal output' + echo 'This is an error message' >&2 + "#).mode(0o755), + file("errors.log", "Old error 1\n"), + ], + check: &[ + contains("errors.log", &["Old error 1"]).msg("Původní obsah zmizel (použili jste 2> místo 2>>?). Původní stav vrátí 'shellgame reset'.").missing("Soubor errors.log chybí. Původní stav vrátí 'shellgame reset'."), + contains("errors.log", &["This is an error message"]).msg("Soubor neobsahuje novou chybu."), + ], + solution: r#" + ./buggy.sh 2>> errors.log + shellgame submit + "#, + success: "Správně! Dvojitá šipka zapisuje na konec, takže předchozí záznamy v logu zůstaly zachovány.", + ..LEVEL + }, + Level { + title: "Všechny výstupy", + text: r#" + Někdy chcete zachytit VŠECHNO - normální výstup i chyby do jednoho souboru. + K tomu slouží `&>`. + + ### Proč je to užitečné + Při ladění skriptů nebo automatizaci často potřebujete kompletní log + všeho, co program vypsal - ať už to byla informace nebo chyba. + + ### Úkol + Spusťte `buggy.sh` a přesměrujte OBOJÍ (stdout i stderr) do `all_output.log`. + + ### Příkazy + - `./script &> soubor` - přesměruje stdout i stderr + + ### Odevzdání + Po vytvoření souboru spusťte: + `shellgame submit` + "#, + hints: &[ + "Ampersand (&) v tomto kontextu znamená 'obojí' - stdout i stderr.", + "Kombinace &> je zkratka pro přesměrování obou výstupů.", + "Použijte './buggy.sh &> all_output.log' a potom 'shellgame submit'.", + ], + setup: &[ + clean("all_output.log"), + file("buggy.sh", r#" + #!/bin/bash + echo 'This is normal output' + echo 'This is an error message' >&2 + "#).mode(0o755), + ], + check: &[ + contains("all_output.log", &["This is normal output", "This is an error message"]) + .msg("Soubor neobsahuje oba typy výstupů. Chybí-li normální výstup, přesměrovali jste jen chyby (2>). Chybí-li chyby, zůstaly na obrazovce, protože samotné > bere jen stdout.") + .missing("Soubor all_output.log zatím neexistuje."), + ], + solution: r#" + ./buggy.sh &> all_output.log + shellgame submit + "#, + success: "Správně! Operátor &> poslal do souboru oba proudy najednou.", + ..LEVEL + }, + Level { + title: "Černá díra", + text: r#" + `/dev/null` je speciální soubor, který zahodí všechno, co do něj pošlete. + Je užitečný pro umlčení hlučných příkazů. + + ### Proč je to užitečné + Některé příkazy vypisují spoustu informací, které nepotřebujete. + Místo zahlcení obrazovky je můžete "poslat do černé díry". + + ### Úkol + Spusťte `buggy.sh` a umlčte VŠECHNY výstupy (stdout i stderr) přesměrováním do `/dev/null`. + Na obrazovce se nesmí objevit nic. + + ### Příkazy + - `./script &> /dev/null` - zahodí veškerý výstup + + ### Odevzdání + Po spuštění umlčeného skriptu spusťte: + `shellgame submit` + "#, + hints: &[ + "Kam v Linuxu 'vyhodíte' data, která nechcete? Existuje speciální soubor...", + "Soubor /dev/null je jako černá díra - vše pohltí a nic nevrátí. Přesměrujte do něj oba proudy.", + "Použijte './buggy.sh &> /dev/null' a potom 'shellgame submit'.", + ], + setup: &[ + file("buggy.sh", r#" + #!/bin/bash + echo 'This is normal output' + echo 'This is an error message' >&2 + # Zjistí, zda jste výstup i chybový výstup poslali do /dev/null. + if [ /dev/stdout -ef /dev/null ] && [ /dev/stderr -ef /dev/null ]; then touch "$(dirname "$0")/.silenced"; fi + "#).mode(0o755), + clean(".silenced"), + ], + check: &[is_file(".silenced").msg("Spusťte './buggy.sh' a přesměrujte stdout i stderr do /dev/null, aby se na obrazovce neobjevilo nic.")], + solution: r#" + ./buggy.sh &> /dev/null + shellgame submit + "#, + success: "Správně! Zápis do /dev/null se rovnou zahodí, takže příkaz umlčíte bez zakládání logu.", + ..LEVEL + }, + Level { + title: "Souhrn Sekce 10", + text: r#" + ### Výzva: Oddělení streamů + + Ukažte, že umíte uložit stdout a stderr odděleně. + + ### Úkol + V `level-10/challenge` je skript `mixed.sh`, který vypisuje: + - normální výstup na stdout + - chyby na stderr + + Spusťte skript **jednou** a současně uložte: + 1. pouze normální výstup do `output.log` + 2. pouze chyby do `errors.log` + + Odpovězte: kolik řádků má errors.log a kolik output.log? + Formát: `chyby,výstup` (např. `3,5`) + + ### Připomenutí + - `>` přesměruje standardní výstup. + - `2>` přesměruje chybový výstup. + + V této výzvě potřebujete oba proudy zachytit zvlášť. + + ### Odevzdání + `shellgame submit <chyby>,<výstup>` + "#, + hints: &[ + "Chyby se zapisují na stderr (deskriptor 2), standardní výstup na stdout (deskriptor 1). Každý proud může mít vlastní cíl.", + "Za jeden příkaz lze zapsat přesměrování '>' i '2>'; pořadí zde nevadí, protože oba proudy míří do různých souborů.", + "Spusťte './mixed.sh > output.log 2> errors.log'. Počty zjistěte pomocí 'wc -l errors.log output.log'.", + ], + start: "challenge", + setup: &[ + clean("challenge"), + file("challenge/mixed.sh", r#" + #!/bin/bash + echo "Line 1 - normal output" + echo "ERROR: Something went wrong" >&2 + echo "Line 2 - more output" + echo "ERROR: Another problem" >&2 + echo "Line 3 - final output" + "#).mode(0o755), + ], + answer: parts(",", &[ + int(2).error("Počet chyb není správný. Zkontrolujte, že do errors.log směřuje pouze deskriptor 2."), + int(3).error("Počet normálních řádků není správný. Zkontrolujte, že do output.log směřuje pouze standardní výstup."), + ]) + .format("Formát odpovědi: chyby,výstup (např. 3,5)"), + check: &[ + content_is("challenge/errors.log", r#" + ERROR: Something went wrong + ERROR: Another problem + "#) + .msg("errors.log neobsahuje přesně pouze chybový výstup skriptu. Buď do něj spadl i standardní výstup (oba proudy míří do jednoho souboru), nebo jste skript spustili víckrát a záznamy se nasčítaly.") + .missing("Soubor errors.log zatím neexistuje."), + content_is("challenge/output.log", r#" + Line 1 - normal output + Line 2 - more output + Line 3 - final output + "#) + .msg("output.log neobsahuje přesně pouze standardní výstup skriptu. Buď v něm skončily i chyby (chybí samostatné přesměrování druhého proudu), nebo jste skript spustili víckrát a záznamy se nasčítaly.") + .missing("Soubor output.log zatím neexistuje."), + ], + solution: r#" + ./mixed.sh > output.log 2> errors.log + shellgame submit "$(wc -l < errors.log),$(wc -l < output.log)" + "#, + success: "Perfektní! Dokončili jste Sekci 10. Stdout a stderr jsou pro vás jako otevřená kniha!", + ..LEVEL + }, + ], +}; diff --git a/src/levels/s11_search.rs b/src/levels/s11_search.rs new file mode 100644 index 0000000..3e34322 --- /dev/null +++ b/src/levels/s11_search.rs @@ -0,0 +1,370 @@ +//! Searching: `grep` for text inside files and `find` for the files themselves. +//! +//! **Purpose.** The finale: search instead of browsing. Results are saved and counted with +//! redirection and pipes, patterns need quoting, and the last challenge reuses earlier tools. +//! +//! **Learning objectives.** After this section a student can: +//! - pull the matching lines out of a file with `grep PATTERN file` and save them with `>`; +//! - search a whole tree for a text with `grep -r` and report the path it prints; +//! - ignore case with `grep -i` and count the matching lines with `| wc -l`; +//! - find files by name and type below a directory with `find DIR -type f -name "NAME"`, quoting +//! the pattern (`"*.py"`) so that `find`, not the shell, evaluates it, and sort the result; +//! - combine `find`, `grep`, `cp` and counting to track down a secret in a larger tree. +//! +//! **Introduces:** `grep`, `grep -r`, `grep -i`, `find` (`-type f`, `-name`, quoted patterns).\ +//! **Relies on:** [redirection](super::s09_redirection) (`>`, `|`, `wc -l`, `sort`), +//! [wildcards](super::s07_wildcards) (patterns), [copy_move](super::s06_copy_move) (`cp`).\ +//! **Leaves for later:** the last section; not in the course: `find -exec` and `-size`, `xargs`, +//! regular expressions. + +use crate::game::*; + +#[rustfmt::skip] +pub const SECTION: Section = Section { + title: "Vyhledávání", + intro: r#" + V této závěrečné sekci se naučíte, jak najít text uvnitř souborů a jak najít samotné soubory. + + ### Dva typy hledání + ``` + grep = Hledání UVNITŘ souborů (obsah) + find = Hledání SOUBORŮ samotných (podle názvu, typu, velikosti...) + ``` + + ### grep - hledání textu + ``` + grep "error" log.txt Najde řádky s "error" + grep -r "TODO" projekt/ Rekurzivně v celém adresáři + grep -i "warning" *.log Ignoruje velikost písmen + ``` + + ### find - hledání souborů + ``` + find . -type f -name "*.py" Všechny běžné .py soubory + find /home -type f -name "config*" Soubory začínající na "config" + find . -type d -name "test*" Pouze adresáře + ``` + + Uvozovky kolem `"*.py"` jsou důležité: zabrání shellu, aby hvězdičku + rozbalil předem. Doslovný vzor tak dostane příkaz `find`, který ho vyhodnotí + v každém prohledávaném adresáři. Ve fish navíc vzor bez uvozovek, který + v aktuálním adresáři nic nenajde, skončí chybou a příkaz se vůbec nespustí. + + ### Proč je to důležité? + ``` + Ztratili jste soubor? → find . -type f -name "soubor.txt" + Hledáte, kde je chyba v kódu? → grep -r "ERROR" src/ + Kolik poznámek TODO je v kódu? → grep -r "TODO" . | wc -l + ``` + + ### Co se naučíte + - Hledat text v souborech (`grep`) + - Rekurzivní hledání (`grep -r`) + - Hledat soubory podle typu a názvu (`find -type f -name`) + - Kombinovat nástroje + "#, + levels: &[ + Level { + title: "Hledání v souboru (grep)", + text: r#" + ### Cíl + Najděte řádek obsahující `ACTIVE_PROFILE` v souboru `config.txt` a uložte ho do `profile.txt`. + + ### Příkazy + - `grep "vzor" soubor` - hledá vzor v souboru + - `grep "vzor" soubor > výstup` - uloží nalezené řádky do souboru + + ### Úkol + 1. Najděte řádek s `ACTIVE_PROFILE` + 2. Přesměrujte výsledek do `profile.txt` + + ### Odevzdání + Po vytvoření souboru spusťte: + `shellgame submit` + "#, + hints: &[ + "Příkaz 'grep' hledá zadaný vzor v souborech.", + "Vyhledejte klíč 'ACTIVE_PROFILE' v souboru 'config.txt' a výstup přesměrujte pomocí '>' do profile.txt.", + "Spusťte 'grep ACTIVE_PROFILE config.txt > profile.txt', výsledek ověřte pomocí 'cat profile.txt' a potom spusťte 'shellgame submit'.", + ], + start: "grep", + setup: &[ + clean("grep/profile.txt"), + clean("grep/config.txt"), + file("grep/config.txt", r#" + user=admin + host=localhost + port=8080 + ACTIVE_PROFILE=production + debug=true + "#), + ], + check: &[ + contains("grep/config.txt", &["ACTIVE_PROFILE=production"]) + .msg("config.txt už neobsahuje hledaný řádek. Přesměrování '>' do souboru, ze kterého grep zároveň čte, ho nejdřív vyprázdní. Obnovte level příkazem 'shellgame reset' a výstup ukládejte do profile.txt.") + .missing("Zdrojový soubor config.txt chybí. Obnovte level příkazem 'shellgame reset'."), + content_is("grep/profile.txt", "ACTIVE_PROFILE=production\n") + .msg("profile.txt má obsahovat přesně jeden řádek, ten s ACTIVE_PROFILE, tak jak ho grep vypíše (včetně konce řádku), a nic dalšího.") + .missing("Soubor profile.txt zatím neexistuje. Uložte do něj výstup grepu pomocí '>'."), + ], + solution: r#" + grep ACTIVE_PROFILE config.txt > profile.txt + shellgame submit + "#, + success: "Správně! grep vybere jen potřebné řádky a '>' je uloží pro další použití.", + ..LEVEL + }, + Level { + title: "Rekurzivní hledání", + text: r#" + ### Cíl + Pomocí `grep -r` zjistěte, který soubor v adresáři `project` obsahuje text `SECRET_KEY`. + + ### Příkazy + - `grep -r "vzor" adresář/` - rekurzivní hledání + + ### Úkol + Najděte soubor obsahující `SECRET_KEY` a odevzdejte jeho relativní cestu. + + ### Odevzdání + `shellgame submit <cesta>` + "#, + hints: &[ + "Přepínač '-r' hledá rekurzivně ve všech podadresářích.", + r#"Zkuste: grep -r "SECRET_KEY" project/"#, + "grep -r vypíše cestu před dvojtečkou. Odevzdejte právě tu cestu.", + ], + start: "recursive", + setup: &[ + file("recursive/project/README.md", "# Project\n"), + file("recursive/project/src/main.py", "print('Hello')\n"), + file("recursive/project/config/settings.py", r#" + SECRET_KEY = 'xyz' + DEBUG = True + "#), + ], + answer: suffix("config/settings.py") + .error("To není správná odpověď. 'grep -r' vypisuje před dvojtečkou celou relativní cestu — odevzdejte ji i s adresáři, ne jen samotný název souboru. Úvodní './' nevadí."), + solution: r#"shellgame submit "$(grep -r SECRET_KEY project/ | cut -d: -f1)""#, + success: "Správně! S přepínačem -r prochází grep celý strom adresářů a u každého nálezu uvede cestu.", + ..LEVEL + }, + Level { + title: "Hledání bez ohledu na velikost písmen", + text: r#" + ### Cíl + Najděte všechny řádky obsahující slovo `warning` bez ohledu na velikost písmen a spočítejte je. + + ### Příkazy + - `grep -i "vzor" soubor` - ignoruje velikost písmen + - `grep -i "vzor" soubor | wc -l` - spočítá nalezené řádky + + ### Úkol + Spočítejte, kolik řádků v `messages.log` obsahuje `warning` psané malými, velkými či smíšenými písmeny. + + ### Odevzdání + `shellgame submit <číslo>` + "#, + hints: &[ + "Bez '-i' najdete jen některé varianty. S '-i' najdete warning, WARNING, Warning…", + r#"Zkuste: grep -i "warning" messages.log a spočítejte vypsané řádky."#, + "Počítejte každý řádek, který grep -i vypíše — ne jen jednu velikost písmen.", + ], + start: "case", + setup: &[ + file("case/messages.log", r#" + 2024-01-01 10:00:00 INFO: Server started + 2024-01-01 10:05:00 WARNING: Memory usage high + 2024-01-01 10:10:00 ERROR: Connection lost + 2024-01-01 10:15:00 warning: Disk space low + 2024-01-01 10:20:00 INFO: User logged in + 2024-01-01 10:25:00 Warning: CPU temperature elevated + 2024-01-01 10:30:00 DEBUG: Cache cleared + 2024-01-01 10:35:00 WARNING: Network latency detected + 2024-01-01 10:40:00 INFO: Backup completed + "#), + ], + answer: int(4) + .mistakes(&[ + ("2", "Našli jste jen 'WARNING'. Použijte -i pro nalezení všech variant (warning, Warning...)."), + ("1", "Našli jste jen jednu variantu. Přepínač -i ignoruje velikost písmen."), + ]) + .error("Počet není správný. Zkontrolujte, že hledáte bez ohledu na velikost písmen."), + solution: r#"shellgame submit "$(grep -i warning messages.log | wc -l)""#, + success: "Správně! Přepínač -i je nepostradatelný pro robustní hledání.", + ..LEVEL + }, + Level { + title: "Hledání souborů (find)", + text: r#" + ### Cíl + Pomocí `find` najděte soubor `lost_file.txt` někde uvnitř `messy_dir`. + + ### Příkazy + - `find adresář -type f -name "název"` - hledá pouze soubory podle názvu + + ### Úkol + Najděte `lost_file.txt` a odevzdejte celou relativní cestu. + + ### Odevzdání + `shellgame submit <cesta>` + "#, + hints: &[ + "Příkaz 'find' prohledá všechny podadresáře automaticky.", + r#"Pro hledání souborů použijte: find kde_hledat -type f -name "co_hledat""#, + r#"Použijte: find messy_dir -type f -name "lost_file.txt""#, + ], + start: "find", + setup: &[ + file("find/messy_dir/a/b/c/d/lost_file.txt", ""), + file("find/messy_dir/other.txt", ""), + file("find/messy_dir/a/junk.txt", ""), + ], + answer: suffix("a/b/c/d/lost_file.txt") + .mistakes(&[ + ("messy_dir/a/b/c/d", "To je cesta k adresáři. Odevzdejte celou cestu i s názvem souboru na konci."), + ("./messy_dir/a/b/c/d", "To je cesta k adresáři. Odevzdejte celou cestu i s názvem souboru na konci."), + ]) + .error("To není správná cesta. Odevzdejte ji tak, jak ji vypsal find — včetně všech adresářů, ne jen název souboru. Úvodní './' nevadí."), + solution: r#"shellgame submit "$(find messy_dir -type f -name 'lost_file.txt')""#, + success: "Správně! find hledá podle vlastností souboru, takže si poradí i s neznámým umístěním.", + ..LEVEL + }, + Level { + title: "Hledání podle přípony", + text: r#" + ### Cíl + Najděte všechny běžné `.py` soubory v adresáři `src_code` a uložte jejich cesty do `python_files.txt`. + Výstup seřaďte, aby měl vždy stejné pořadí. + + **Důležité:** Vzor musí být v uvozovkách: `"*.py"`. V aktuálním adresáři leží `setup.py`, + takže bez uvozovek by shell `*.py` ještě před spuštěním nahradil jménem `setup.py` + a `find` by hledal jen soubory s tímto jménem. S uvozovkami dostane vzor doslova `find`. + + ### Stavební prvky + - `find adresář -type f -name "*.py"` - hledá pouze soubory s příponou `.py` + - `sort` - seřadí řádky + - `>` - uloží výsledek + + ### Úkol + Vytvořte `python_files.txt` se seřazenými cestami nalezených souborů, jednou na řádek, + tak jak je vypíše `find` spuštěný na adresář `src_code` (cesty tedy začínají `src_code/`). + + ### Odevzdání + Po vytvoření souboru spusťte: + `shellgame submit` + "#, + hints: &[ + "Omezte find na běžné soubory pomocí '-type f'; adresář s příponou .py se počítat nemá.", + "Vzor dejte do uvozovek a výstup pošlete přes 'sort' před uložením pomocí '>'.", + r#"Použijte: find src_code -type f -name "*.py" | sort > python_files.txt"#, + ], + start: "extension", + setup: &[ + clean("extension/python_files.txt"), + file("extension/setup.py", ""), + dir("extension/src_code/archive.py"), + file("extension/src_code/main.py", ""), + file("extension/src_code/utils.py", ""), + file("extension/src_code/README.txt", ""), + file("extension/src_code/data.csv", ""), + ], + check: &[ + content_is("extension/python_files.txt", r#" + src_code/main.py + src_code/utils.py + "#) + .msg("Obsah python_files.txt nesedí. Má obsahovat jen cesty k běžným .py souborům v src_code, každou na vlastním řádku, začínající 'src_code/' a seřazené. Bez -type f se přidá adresář archive.py; bez uvozovek kolem vzoru shell vzor nahradí jménem setup.py; 'find .' místo 'find src_code' přidá './' a setup.py; a nezapomeňte na sort.") + .missing("Soubor python_files.txt zatím neexistuje. Výstup příkazu uložte pomocí '>'."), + ], + solution: r#" + find src_code -type f -name "*.py" | sort > python_files.txt + shellgame submit + "#, + success: "Správně! Spojení find, sort a '>' dává výsledek, který vyjde pokaždé stejně.", + ..LEVEL + }, + Level { + title: "Finální výzva", + text: r#" + ### 🏆 Finální výzva: Terminálový ninja + + Gratulujeme! Dostali jste se na konec ShellGame. + Tato výzva kombinuje vyhledávání, filtrování, kopírování a počítání z posledních sekcí. + + ### Úkol + V `level-11/final`: + + 1. Najděte **všechny soubory** s příponou `.sh` rekurzivně pomocí `find`. + 2. Jeden z nich obsahuje tajný kód — najděte ho pomocí `grep`. + 3. Skript s kódem zkopírujte do `found/secret.txt`. + 4. Spočítejte soubory `.sh`. Kopie má příponu `.txt`, takže se do počtu nezapočítá. + + ### Struktura odpovědi + `<počet_skriptů>,<tajný_kód>` + + Kód je jen hodnota za rovnítkem, ne celý řádek. + + ### Odevzdání + `shellgame submit <počet>,<kód>` + "#, + hints: &[ + "Pro hledání skriptů spojte 'find' s podmínkami '-type f' a '-name'; obsah pak prohledejte pomocí 'grep'.", + r#"Skripty vypíše find . -type f -name "*.sh". V jejich obsahu hledejte řádek se slovem SECRET, například pomocí grep -r SECRET ."#, + r#"Skript s kódem zkopírujte pomocí cp do found/secret.txt a počet zjistěte přes find . -type f -name "*.sh" | wc -l"#, + "Odevzdejte počet a samotný kód, tedy hodnotu za '='.", + ], + start: "final", + setup: &[ + clean("final"), + dir("final/found"), + file("final/run.sh", r#" + #!/bin/bash + echo 'Running...' + "#), + file("final/scripts/build.sh", r#" + #!/bin/bash + make all + "#), + file("final/scripts/test.sh", r#" + #!/bin/bash + pytest + "#), + file("final/hidden/secret.sh", r#" + #!/bin/bash + # SECRET_CODE=NINJA2024 + echo 'You found me!' + "#), + file("final/readme.txt", "Look for shell scripts!\n"), + file("final/data.csv", "col1,col2\n"), + ], + answer: parts(",", &[ + int(4).error(r#"Počet .sh souborů není správný. Použijte 'find . -type f -name "*.sh"'."#), + exact("NINJA2024") + .nocase() + .mistakes(&[ + ("SECRET_CODE=NINJA2024", "Odevzdejte jen hodnotu za '=', ne celé přiřazení."), + ("# SECRET_CODE=NINJA2024", "Odevzdejte jen hodnotu za '=', ne celý řádek."), + ]) + .error("Tajný kód nesedí. Najděte ho pomocí 'grep' v obsahu skriptů a odevzdejte jen hodnotu za '=', ne celý řádek."), + ]) + .format("Formát: počet,kód (např. 5,ABC123)"), + check: &[ + same("final/hidden/secret.sh", "final/found/secret.txt") + .msg("found/secret.txt se neshoduje se skriptem, který obsahuje tajný kód. Zkopírujte ho pomocí 'cp'.") + .missing("V adresáři found/ chybí secret.txt: zkopírujte do něj skript s tajným kódem."), + ], + solution: r#" + cp hidden/secret.sh found/secret.txt + shellgame submit "$(find . -type f -name '*.sh' | wc -l),$(grep -rh SECRET_CODE --include='*.sh' . | cut -d= -f2)" + "#, + success: r#" + Gratulujeme, dokončili jste ShellGame! + + Zvládáte navigaci v souborovém systému, práci se soubory a adresáři, oprávnění, + přesměrování, žolíky i vyhledávání. Jste připraveni na další dobrodružství v Linuxu! + "#, + ..LEVEL + }, + ], +}; diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..7bfbf50 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,12 @@ +// The front page of the docs site: .github/workflows/docs.yml publishes `cargo doc` to GitHub Pages. +#![doc = include_str!("../README.md")] + +pub mod cli; +pub mod env; +pub mod game; +pub mod levels; +pub mod sandbox; +pub mod session; +pub mod shell; +pub mod state; +pub mod ui; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..100520f --- /dev/null +++ b/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + std::process::exit(shellgame::cli::main()); +} diff --git a/src/sandbox.rs b/src/sandbox.rs new file mode 100644 index 0000000..f3acb0e --- /dev/null +++ b/src/sandbox.rs @@ -0,0 +1,107 @@ +//! Isolation of the game shell: outside `/tmp`, the state directory and `/dev` nothing can be changed. +//! +//! [`confine`] applies a Landlock ruleset to a command between fork and exec, so the shell, every +//! program it starts and the game binary all inherit it. It handles the write rights of ABI 5 +//! (create, delete, rename, write, truncate, device ioctl), best effort on older kernels; reading +//! and executing stay free. Newer ABIs are not requested on purpose: ABI 9 would forbid connecting to +//! unix sockets (X11, Wayland, D-Bus, ssh-agent), and signal scoping is misreported by RHEL 9. +//! Landlock cannot restrict `chmod`, so the shell scripts send a typed `chmod` through [`guard`]. +//! Only Linux can confine; elsewhere [`confine`] fails and [`probe`] says no. + +use std::ffi::OsStr; +use std::fs; +use std::io; +use std::path::Path; +use std::process::{Command, Stdio}; + +use crate::game::paths::inside_tmp; + +/// Makes `cmd` start confined to writing in `/tmp`, `state_dir` and `/dev`. The ruleset is built +/// here, in the calling process, which is never confined itself; the child applies it just before +/// exec. When the kernel cannot enforce it, the exec fails (with `ENOSYS`), so a command is never +/// started unconfined by accident. `restrict_self` also sets no_new_privs: `sudo` and `su` stop +/// working inside. +#[cfg(target_os = "linux")] +pub fn confine(cmd: &mut Command, state_dir: &Path) -> io::Result<()> { + use std::os::unix::process::CommandExt; + + use landlock::{ + ABI, AccessFs, Ruleset, RulesetAttr, RulesetCreatedAttr, RulesetStatus, path_beneath_rules, + }; + + let write = AccessFs::from_write(ABI::V5); + let writable = [Path::new("/tmp"), state_dir, Path::new("/dev")]; + let ruleset = Ruleset::default() + .handle_access(write) + .and_then(|ruleset| ruleset.create()) + .and_then(|ruleset| ruleset.add_rules(path_beneath_rules(writable, write))) + .map_err(io::Error::other)?; + let mut ruleset = Some(ruleset); + // SAFETY: the closure runs in the forked child and only makes two system calls (no_new_privs + // and landlock_restrict_self; no allocation unless it fails). The launcher is single-threaded + // when it spawns, so nothing else can hold a lock in the child. + unsafe { + cmd.pre_exec(move || { + match ruleset.take().and_then(|ruleset| ruleset.restrict_self().ok()) { + Some(status) if status.ruleset != RulesetStatus::NotEnforced => Ok(()), + _ => Err(rustix::io::Errno::NOSYS.into()), + } + }); + } + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +pub fn confine(_cmd: &mut Command, _state_dir: &Path) -> io::Result<()> { + Err(io::ErrorKind::Unsupported.into()) +} + +/// Whether confinement works here: `<binary> --version` runs successfully under [`confine`]. +pub fn probe(binary: &Path, state_dir: &Path) -> bool { + let mut cmd = Command::new(binary); + cmd.arg("--version").stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()); + confine(&mut cmd, state_dir).is_ok() && cmd.status().is_ok_and(|status| status.success()) +} + +/// The `chmod` guard: refuses (with the text for the player) when an argument names an existing path +/// that does not resolve to somewhere strictly inside `/tmp`. Arguments are taken relative to `cwd`, +/// symlinks are resolved; modes, options and paths that do not exist pass, and `chmod` reports them. +pub fn guard<S: AsRef<OsStr>>(cwd: &Path, args: &[S]) -> Result<(), String> { + for arg in args.iter().map(AsRef::as_ref).filter(|arg| !arg.is_empty()) { + let path = cwd.join(arg); + if fs::symlink_metadata(&path).is_ok() && !inside_tmp(&path) { + return Err(format!( + "Hra chrání soubory mimo /tmp: chmod pro „{}“ byl zablokován.", + Path::new(arg).display() + )); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::os::unix::fs::symlink; + + use super::*; + + #[test] + fn chmod_is_allowed_only_strictly_inside_tmp() { + let root = tempfile::Builder::new().tempdir_in("/tmp").unwrap(); + let (a, b) = (root.path().join("a"), root.path().join("a/b")); + fs::create_dir_all(&b).unwrap(); + fs::write(b.join("file"), "").unwrap(); + symlink("/etc/hosts", a.join("out")).unwrap(); + symlink("b/file", a.join("in")).unwrap(); + let ok = |cwd: &Path, args: &[&str]| guard(cwd, args).is_ok(); + + assert!(ok(&a, &["u+x", "755", "b/file", "in", "-R", "--", "missing", ""])); + assert!(ok(&b, &["..", "../b", "file"]), "`..` stays inside /tmp"); + assert!(!ok(root.path(), &[".."]), "`..` is /tmp itself"); + assert!(!ok(&a, &["../.."]) && !ok(&b, &["../../.."]), "`..` is /tmp itself"); + assert!(!ok(root.path(), &["../.."]), "`..` leaves /tmp"); + assert!(!ok(&a, &["755", "out"]), "a link to a file in /etc"); + assert!(!ok(&a, &["/etc/hosts"]) && !ok(Path::new("/"), &["etc"])); + assert!(guard(&a, &[OsStr::new("/etc")]).unwrap_err().contains("„/etc“")); + } +} diff --git a/src/session.rs b/src/session.rs new file mode 100644 index 0000000..b00341d --- /dev/null +++ b/src/session.rs @@ -0,0 +1,611 @@ +//! What every `shellgame` command does, on top of the course ([`crate::game`]), the saved game +//! ([`crate::state`]) and the screens ([`crate::ui`]). +//! +//! The wrapping shell is asked to export variables and to `cd` with directives, which are emitted +//! *before* any Enter prompt, so a Ctrl-C at the prompt (which ends it like Enter) cannot lose them. +//! The player's directory is tracked in memory and updated whenever the game moves them, so a +//! second check in the same run neither repeats the directive nor the notice. + +mod update; + +use std::ffi::OsString; +use std::fs; +use std::io; +use std::os::unix::fs::MetadataExt; +use std::path::{Path, PathBuf}; + +use anstyle::Style; + +use crate::cli::version_line; +use crate::env::Env; +use crate::game::paths::{canonical, inside_tmp}; +use crate::game::{self, CdMove, Ctx, Pos, cd, dedent, messages}; +use crate::levels::COURSE; +use crate::shell::{Directives, ENV_WORKSPACE}; +use crate::state::{self, Progress, Saved, State}; +use crate::ui::{ + DIM, DIM_VIOLET, GREEN, Line, RED, Term, YELLOW, markdown, prompt, screens, span, words, +}; + +pub const OK: i32 = 0; +/// A command that could not do what was asked: no game, an unknown level, an error, a declined `remove`. +pub const FAILURE: i32 = 1; +/// A `cd` or `chmod` that is refused. +pub const BLOCKED: i32 = 3; + +const FIRST: Pos = Pos { section: 0, level: 1 }; + +/// Why a command stops early. +pub enum Halt { + /// The player has been told already. + Exit(i32), + /// Something broke: a red panel, status [`FAILURE`]. + Fail(String), +} + +impl From<io::Error> for Halt { + fn from(error: io::Error) -> Halt { + let damaged = error.kind() == io::ErrorKind::InvalidData; + Halt::Fail(if damaged { + error.to_string() + } else { + messages::fill(messages::FILESYSTEM_ERROR, error) + }) + } +} + +pub type Flow<T = ()> = Result<T, Halt>; + +/// The workspace holds what the game prepares and deletes: it must be inside `/tmp` and the player's own. +fn check_workspace(workspace: &Path) -> Result<(), String> { + if !inside_tmp(workspace) { + return Err(format!("Pracovní prostor musí být uvnitř /tmp: {}", workspace.display())); + } + match fs::symlink_metadata(workspace) { + Ok(meta) + if meta.file_type().is_symlink() + || meta.uid() != rustix::process::getuid().as_raw() => + { + Err(format!( + "Pracovní prostor {} musí být adresář, který patří vám (ne odkaz).", + workspace.display() + )) + } + _ => Ok(()), + } +} + +/// Checks the workspace, makes it a usable directory (created when missing, repaired after whatever +/// the player did to it) and checks the result. +fn ensure_workspace(workspace: &Path) -> Flow { + check_workspace(workspace).map_err(Halt::Fail)?; + game::ensure_dir(workspace)?; + check_workspace(workspace).map_err(Halt::Fail) +} + +/// The nearest level at or after a saved position that no longer exists; the last level if none follows. +fn resolve(saved: &str) -> Pos { + let number = |digits: &str| digits.parse().ok(); + let (section, level) = + saved.split_once('.').and_then(|(s, l)| Some((number(s)?, number(l)?))).unwrap_or((0, 0)); + Pos::all() + .find(|pos| *pos >= Pos { section, level }) + .or_else(|| Pos::all().last()) + .unwrap_or(FIRST) +} + +/// The directory a `cd` call names: its first non-option argument (`-` is a target, `--` ends the +/// options, a bare `cd` has the target `""`). +fn cd_target(args: &[String]) -> &str { + let mut options = true; + for arg in args { + match arg.as_str() { + "--" if options => options = false, + option if options && option.starts_with('-') && option.len() > 1 => {} + target => return target, + } + } + "" +} + +pub struct Session<'a> { + env: &'a Env, + term: Term, + shell: Directives, + /// Where the player's shell is. + cwd: Option<PathBuf>, + /// A level was prepared below the player, whose directory may have been recreated underneath + /// the shell: the next teleport asks it to `cd` even to the same path. + stale: bool, + /// An older saved game that was set aside (its new name) and where to resume it: told at the end + /// of the command, after any screen that clears the terminal. + retired: Option<(PathBuf, Option<String>)>, +} + +impl<'a> Session<'a> { + pub fn new(env: &'a Env) -> Session<'a> { + Session { + env, + term: Term::stdout(), + shell: Directives::new(env.wrapped), + cwd: env.cwd.clone(), + stale: false, + retired: None, + } + } + + /// The exit status of a finished command; a failure is shown as a red panel. A saved game that + /// was set aside is told last, so no screen clears it away. + pub fn finish(&self, result: Flow) -> i32 { + let code = match result { + Ok(()) => OK, + Err(Halt::Exit(code)) => code, + Err(Halt::Fail(message)) => { + let lost = self.env.cwd.is_none().then(|| format!("\n\n{}", messages::CWD_MISSING)); + self.print(screens::error(&self.term, &(message + &lost.unwrap_or_default()))); + FAILURE + } + }; + if let Some((file, resume)) = &self.retired { + self.print(screens::retired(&self.term, &file.to_string_lossy(), resume.as_deref())); + } + code + } + + fn print(&self, lines: Vec<Line>) { + self.term.print(&lines); + } + + fn say(&self, style: Style, text: &str) { + self.print(screens::say(&self.term, style, text)); + } + + /// Tells the player and stops the command with `code`. + fn stop<T>(&self, style: Style, text: &str, code: i32) -> Flow<T> { + self.say(style, text); + Err(Halt::Exit(code)) + } + + fn save(&self, state: &State) -> Flow { + Ok(state.save(&self.env.state_dir)?) + } + + /// The saved game. An old file is set aside (the notice comes at the end of the command), which + /// leaves no game. + fn load(&mut self) -> Flow<Option<State>> { + match state::load(&self.env.state_dir)? { + Saved::Game(state) => Ok(Some(state)), + Saved::None => Ok(None), + Saved::Old(resume) => { + self.retired = Some((state::retire(&self.env.state_dir)?, resume)); + Ok(None) + } + } + } + + /// The saved game of a command that needs one. + fn game(&mut self) -> Flow<State> { + match self.load()? { + Some(state) => Ok(state), + None => self.stop(YELLOW, "Hra ještě nezačala. Spusťte: shellgame", FAILURE), + } + } + + /// The game of a command that needs one, at a level that exists: a finished game is inert (the + /// completion screen), a stale position shows the level it was moved to instead of the command. + fn active(&mut self) -> Flow<(State, Pos)> { + let state = self.game()?; + if state.finished { + self.print(screens::complete(&self.term, &state)); + return Err(Halt::Exit(OK)); + } + match self.restore(state)? { + (state, pos, false) => Ok((state, pos)), + (state, pos, true) => self.enter(state, pos, false).and(Err(Halt::Exit(OK))), + } + } + + /// Makes a loaded game playable: a position that no longer exists becomes the nearest following + /// level (with a notice), a vanished workspace (a cleared `/tmp`) is rebuilt. True when the + /// position changed. + fn restore(&mut self, mut state: State) -> Flow<(State, Pos, bool)> { + let missing = !state.workspace.is_dir(); + ensure_workspace(&state.workspace)?; + let (pos, moved) = + state.pos().map_or_else(|| (resolve(&state.level), true), |pos| (pos, false)); + if moved { + self.print(screens::stale(&self.term, &state.level, pos)); + state.go(pos); + self.save(&state)?; + } + if missing { + self.say(YELLOW, "⚠ Pracovní prostor byl smazán (např. restart systému). Obnovuji..."); + self.say(GREEN, &format!("✓ Pracovní prostor obnoven: {}", state.workspace.display())); + } + if missing || moved { + self.prepare_or_report(pos, &state.workspace); + } + Ok((state, pos, moved)) + } + + fn prepare(&mut self, pos: Pos, workspace: &Path) -> io::Result<()> { + let root = canonical(&pos.section_dir(workspace)); + if self.cwd.as_ref().is_some_and(|cwd| cwd.starts_with(&root) && *cwd != root) { + self.stale = true; + } + game::prepare(pos, workspace) + } + + /// Prepares a level, telling the player when that fails; true if it worked. + fn prepare_or_report(&mut self, pos: Pos, workspace: &Path) -> bool { + let prepared = self.prepare(pos, workspace); + if let Err(error) = &prepared { + self.print(screens::setup_error(&self.term, pos, &error.to_string())); + } + prepared.is_ok() + } + + /// Asks the shell to `cd` to the start of the level unless the player is there; returns the + /// directory to announce when they were moved. + fn teleport(&mut self, pos: Pos, workspace: &Path) -> Option<PathBuf> { + let start = game::start_dir(pos, workspace); + let target = canonical(&start); + let there = self.cwd.as_deref() == Some(target.as_path()); + if there && !self.stale { + return None; + } + self.shell.cd(&start); + self.stale = false; + if !start.is_dir() { + return None; + } + self.cwd = Some(target); + (!there).then_some(start) + } + + /// The export of the workspace and the teleport to the start of the level. + fn arrive(&mut self, state: &State, pos: Pos) -> Option<PathBuf> { + self.shell.export(ENV_WORKSPACE, &state.workspace); + self.teleport(pos, &state.workspace) + } + + /// Pages of a section's intro, Enter between them. Ctrl-C stops paging. + fn intro(&self, section: usize, then_level: bool) { + let text = dedent(COURSE[section].intro); + let pages = markdown::pages(&text); + for (index, page) in pages.iter().enumerate() { + self.term.clear(); + self.print(screens::intro_page(&self.term, section, page, index + 1, pages.len())); + if (index + 1 < pages.len() || then_level) && !prompt::pause(&self.term) { + break; + } + } + } + + /// The assignment, and the line about the isolation of the game shell. + fn show_level(&self, pos: Pos) { + self.term.clear(); + self.print(screens::level(&self.term, pos)); + self.print(screens::banner(&self.term, self.env.sandbox.as_deref())); + } + + /// The screens of a level the player has arrived at, the section's intro first when entering one. + fn present(&self, pos: Pos, notice: Option<PathBuf>, intro: bool) { + if intro { + self.intro(pos.section, true); + } + self.show_level(pos); + if let Some(dir) = notice { + self.print(screens::teleport(&self.term, &dir.to_string_lossy())); + } + } + + /// Puts the player at a level: its clock starts, the shell is moved, the screens are shown. + fn enter(&mut self, mut state: State, pos: Pos, intro: bool) -> Flow { + if state.go(pos) { + self.save(&state)?; + } + let notice = self.arrive(&state, pos); + self.present(pos, notice, intro); + Ok(()) + } + + /// `shellgame` inside the game shell: the current level, a new game on the first run. + pub fn current(&mut self) -> Flow { + let Some(state) = self.load()? else { + return self.first_run(); + }; + if state.finished { + self.print(screens::complete(&self.term, &state)); + return Ok(()); + } + let (state, pos, _) = self.restore(state)?; + self.enter(state, pos, false) + } + + fn first_run(&mut self) -> Flow { + let workspace = self.env.workspace.clone(); + ensure_workspace(&workspace)?; + let state = State::new(workspace, FIRST); + self.prepare_or_report(FIRST, &state.workspace); + self.save(&state)?; + self.print(screens::init(&self.term, &self.env.user, &state.workspace.to_string_lossy())); + self.enter(state, FIRST, true) + } + + /// The hints unlocked so far and the next one, if there is one left; with `repeat` only the + /// unlocked ones. + pub fn hint(&mut self, repeat: bool) -> Flow { + let (mut state, pos) = self.active()?; + let texts = pos.level().hints; + let mut shown = (state.at(pos).hints as usize).min(texts.len()); + if repeat && shown == 0 { + self.say(YELLOW, "Zatím jste v tomto levelu žádnou nápovědu neodemkli."); + self.print(screens::cmd_note( + &self.term, + "První odemknete příkazem: ", + "shellgame hint", + )); + self.print(vec![Line::new()]); + return Ok(()); + } + if !repeat && shown < texts.len() { + shown += 1; + state.at(pos).hints += 1; + self.save(&state)?; + } + self.print(screens::hints(&self.term, texts, shown)); + Ok(()) + } + + /// A wrong answer is counted and explained; a right one is recorded and the game moves on. + pub fn submit(&mut self, answer: Option<String>) -> Flow { + let (mut state, pos) = self.active()?; + let ctx = Ctx::new(&state.workspace, pos, self.cwd.clone(), &self.env.home); + if let Err(message) = pos.level().judge(&ctx, answer.as_deref()) { + state.at(pos).attempts += 1; + self.save(&state)?; + self.print(screens::failure(&self.term, &message)); + return Ok(()); + } + state.at(pos).done = Some(state::now()); + let done = state.at(pos).clone(); + self.advance(state, pos, done) + } + + fn show_success(&self, pos: Pos, done: &Progress) { + self.print(screens::success(&self.term, &dedent(pos.level().success), done)); + } + + /// Leaves `pos` with ONE atomic save (the completion, the next position and its clock), so a + /// next level that cannot be prepared never costs the player their progress. + fn advance(&mut self, mut state: State, pos: Pos, done: Progress) -> Flow { + let Some(next) = pos.next() else { + state.finished = true; + self.save(&state)?; + self.show_success(pos, &done); + self.print(screens::complete(&self.term, &state)); + return Ok(()); + }; + state.go(next); + self.save(&state)?; + let ready = self.prepare_or_report(next, &state.workspace); + let notice = if ready { self.arrive(&state, next) } else { None }; + self.show_success(pos, &done); + prompt::pause(&self.term); + if ready { + self.present(next, notice, next.section != pos.section); + } + Ok(()) + } + + /// Rebuilds the level and puts the player back at its start, wherever they wandered off to. + pub fn reset(&mut self) -> Flow { + let (state, pos) = self.active()?; + if let Err(error) = self.prepare(pos, &state.workspace) { + return self.stop(RED, &format!("Chyba při obnově levelu: {error}"), FAILURE); + } + let notice = self.arrive(&state, pos); + self.show_level(pos); + self.say(GREEN, &format!("✓ Level {pos} byl resetován.")); + if let Some(dir) = notice { + self.print(screens::teleport(&self.term, &dir.to_string_lossy())); + } + Ok(()) + } + + /// The position a command line names: `S.L`, or `S` for the first level of a section. + fn position(&self, target: &str) -> Flow<Pos> { + target.parse().map_err(|error| { + self.say(RED, &format!("Chyba: {error}")); + self.print(screens::cmd_note(&self.term, "Seznam levelů: ", "shellgame levels")); + Halt::Exit(FAILURE) + }) + } + + /// Shows the assignment of a level (`S.L`) or the intro of a section (`S`) again, the current + /// level's assignment without a target (the completion screen once the course is finished); the + /// game is not touched. + pub fn show(&mut self, target: Option<&str>) -> Flow { + let Some(target) = target else { + let (_, current) = self.active()?; + self.show_level(current); + return Ok(()); + }; + self.game()?; + let pos = self.position(target)?; + if target.contains('.') { + self.show_level(pos); + } else { + self.intro(pos.section, false); + } + Ok(()) + } + + /// Continues at a level (`S.L`) or at the first level of a section after its intro (`S`), + /// starting a game if there is none. Every record is kept (earlier levels are not marked + /// completed), the level's clock starts if it has not, a finished game is open again. + /// Without a valid target: the error and the levels to choose from. + pub fn resume(&mut self, target: Option<&str>) -> Flow { + let Some(target) = target else { + self.say(RED, "Chyba: Chybí ID levelu."); + self.print(screens::cmd_note(&self.term, "Použití: ", "shellgame resume <S.L | S>")); + let state = self.load()?; + self.print(screens::levels(&self.term, state.as_ref())); + return Err(Halt::Exit(FAILURE)); + }; + let pos = self.position(target)?; + let mut state = match self.load()? { + Some(state) => state, + None => State::new(self.env.workspace.clone(), pos), + }; + ensure_workspace(&state.workspace)?; + state.finished = false; + state.go(pos); + if !self.prepare_or_report(pos, &state.workspace) { + return Err(Halt::Exit(FAILURE)); + } + self.save(&state)?; + let notice = self.arrive(&state, pos); + if !target.contains('.') { + self.intro(pos.section, true); + } + self.show_level(pos); + self.say(GREEN, &format!("✓ Pokračujete od levelu {pos}.")); + if let Some(dir) = notice { + self.print(screens::teleport(&self.term, &dir.to_string_lossy())); + } + Ok(()) + } + + /// Every level, with the saved position and the completed ones marked; works without a game. + pub fn levels(&mut self) -> Flow { + let state = self.load()?; + self.print(screens::levels(&self.term, state.as_ref())); + Ok(()) + } + + pub fn status(&mut self) -> Flow { + let state = self.game()?; + self.print(screens::status(&self.term, &state)); + Ok(()) + } + + /// Deletes the workspace (only inside `/tmp`, and only the player's own) and the saved game, + /// whatever its state; closes the game shell. A declined confirmation is a failure. + pub fn remove(&mut self, yes: bool) -> Flow { + if !self.env.state_dir.join(state::FILE).exists() { + return self.stop(YELLOW, "Není co odstranit: hra ještě nezačala.", FAILURE); + } + if !yes && !prompt::confirm("Opravdu chcete odstranit všechna data ShellGame?") { + return self.stop(YELLOW, "Odstranění zrušeno. Žádná data nebyla smazána.", FAILURE); + } + let workspace = + state::workspace_of(&self.env.state_dir).unwrap_or_else(|| self.env.workspace.clone()); + check_workspace(&workspace) + .map_err(|why| Halt::Fail(format!("Odmítám odstranit pracovní prostor. {why}")))?; + game::remove_tree(&workspace)?; + state::remove(&self.env.state_dir)?; + self.say(GREEN, "Stav ShellGame a pracovní prostor odstraněny."); + self.shell.exit(); + Ok(()) + } + + pub fn exit(&mut self) -> Flow { + if !self.env.wrapped { + return self.stop(YELLOW, "Nejste v herním shellu, není z čeho odejít.", FAILURE); + } + self.say(YELLOW, "Ukončuji ShellGame..."); + if let Ok(Saved::Game(state)) = state::load(&self.env.state_dir) + && !state.finished + { + self.print(screens::resume_hint(&self.term, &state.level)); + } + self.shell.exit(); + Ok(()) + } + + pub fn update(&mut self) -> Flow { + if self.env.sandbox.as_deref() == Some("landlock") { + return self.stop( + RED, + "Aktualizaci spusťte mimo herní shell: shellgame exit, pak shellgame update", + FAILURE, + ); + } + self.say(DIM, "Hledám nejnovější verzi ShellGame..."); + let old = version_line(); + match update::run(self.env, &old) { + Ok(Some(new)) => { + self.say(GREEN, &format!("✓ ShellGame byl aktualizován: {old} -> {new}")) + } + Ok(None) => self.say(GREEN, &format!("Již používáte nejnovější verzi: {old}")), + Err(update::Failed { why, installer }) => { + self.say(RED, &why); + if installer { + let command = + [words(DIM, "Aktualizaci můžete provést znovu instalačním skriptem:")]; + self.print(screens::note(&self.term, &command)); + self.print(vec![vec![span(format!(" {}", update::INSTALLER), DIM_VIOLET)]]); + } + return Err(Halt::Exit(FAILURE)); + } + } + Ok(()) + } + + /// The game a hook looks at. Hooks fail open: no game, an unreadable one or an unknown level + /// mean nothing to enforce, never an error in the player's `cd`. + fn hook_game(&self) -> Option<(State, Pos)> { + let Ok(Saved::Game(state)) = state::load(&self.env.state_dir) else { + return None; + }; + let pos = state.pos()?; + Some((state, pos)) + } + + /// `cd-hook pre|post <before> <after-or-prev> -- <cd args...>`: `pre` may block the move + /// ([`BLOCKED`], with the reason on stderr), `post` records the evidence of a move that happened. + pub fn cd_hook(&self, post: bool, first: &Path, second: &Path, args: &[OsString]) -> i32 { + let Some((state, pos)) = self.hook_game() else { + return OK; + }; + let args: Vec<String> = args.iter().map(|arg| arg.to_string_lossy().into_owned()).collect(); + let ctx = Ctx::new(&state.workspace, pos, self.cwd.clone(), &self.env.home); + let target = cd_target(&args); + if post { + let dest = Some(second).filter(|dir| dir.is_absolute()); + cd::after(pos, &CdMove { target, pwd: first, prev: None, dest, ctx: &ctx }); + return OK; + } + let prev = Some(second).filter(|dir| dir.is_absolute()); + match cd::before(pos, &CdMove { target, pwd: first, prev, dest: None, ctx: &ctx }) { + Ok(()) => OK, + Err(message) => { + let term = Term::stderr(); + term.print(&screens::cd_block(&term, pos, &message)); + BLOCKED + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_cd_target_is_the_first_non_option_argument() { + let target = |args: &[&str]| { + cd_target(&args.iter().map(|arg| arg.to_string()).collect::<Vec<_>>()).to_owned() + }; + assert_eq!( + [target(&[]), target(&["-P"]), target(&[""]), target(&["-"])], + ["", "", "", "-"] + ); + assert_eq!( + [target(&["-L", "-P", "dir", "x"]), target(&["--", "-P"]), target(&["-P", "--", "--"])], + ["dir", "-P", "--"] + ); + } +} diff --git a/src/session/update.rs b/src/session/update.rs new file mode 100644 index 0000000..4556f25 --- /dev/null +++ b/src/session/update.rs @@ -0,0 +1,103 @@ +//! `shellgame update`: download the release archive for this platform (`curl`), unpack it (`tar`), +//! check that the new binary runs and replace the running one. The scratch directory is next to +//! the binary, so the final `rename` stays on one filesystem and is atomic. + +use std::fs; +use std::io; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; +use std::process::{Command, Stdio}; + +use crate::env::Env; + +const RELEASES: &str = "https://github.com/jdupak/ShellGame/releases/latest/download"; +pub const INSTALLER: &str = + "curl -fsSL https://raw.githubusercontent.com/jdupak/ShellGame/main/scripts/install.sh | bash"; + +/// Why the update failed, in Czech; `installer` says that running the installer again would help. +pub struct Failed { + pub why: String, + pub installer: bool, +} + +fn failed(why: String, installer: bool) -> Failed { + Failed { why, installer } +} + +/// `shellgame-<linux|macos>-<x86_64|aarch64>.tar.gz` +fn asset() -> String { + let os = if cfg!(target_os = "macos") { "macos" } else { "linux" }; + let arch = if cfg!(target_arch = "aarch64") { "aarch64" } else { "x86_64" }; + format!("shellgame-{os}-{arch}.tar.gz") +} + +/// Runs a tool and returns its stdout; a failure says `what` went wrong and carries the tool's own +/// words. A missing `curl` or `tar` is no case for the installer, which needs them too. +fn tool(program: &str, command: &mut Command, what: &str) -> Result<String, Failed> { + let output = command.stdin(Stdio::null()).output().map_err(|error| match error.kind() { + io::ErrorKind::NotFound => failed( + format!("Pro aktualizaci je potřeba příkaz '{program}', ale nebyl nalezen."), + false, + ), + _ => failed(format!("{what}: {error}"), false), + })?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let detail = Some(stderr.trim()) + .filter(|text| !text.is_empty()) + .map_or_else(|| output.status.to_string(), str::to_owned); + return Err(failed(format!("{what}: {detail}"), false)); + } + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +/// The new version line, or `None` when the downloaded binary is the one already running. +pub fn run(env: &Env, old: &str) -> Result<Option<String>, Failed> { + let unknown = || failed("Nepodařilo se zjistit, kde je ShellGame nainstalován.".into(), false); + let exe = env.exe.as_deref().ok_or_else(unknown)?; + let dir = exe.parent().ok_or_else(unknown)?; + let url = env.update_url.clone().unwrap_or_else(|| format!("{RELEASES}/{}", asset())); + let scratch = tempfile::Builder::new().prefix(".shellgame-update-").tempdir_in(dir).map_err(|error| { + failed(format!("Do adresáře {} nelze zapisovat, ShellGame se proto nemůže sám aktualizovat ({error}).", dir.display()), true) + })?; + let archive = scratch.path().join(asset()); + tool( + "curl", + // A dead connection must not hang the player's terminal. + Command::new("curl") + .args(["-fsSL", "--connect-timeout", "20", "--max-time", "300", "--retry", "2", "-o"]) + .arg(&archive) + .arg(&url), + &format!("Stažení {url} se nepodařilo"), + )?; + tool( + "tar", + Command::new("tar") + .arg("-xzf") + .arg(&archive) + .arg("-C") + .arg(scratch.path()) + .arg("shellgame"), + "Rozbalení staženého archivu se nepodařilo", + )?; + let fresh = scratch.path().join("shellgame"); + fs::set_permissions(&fresh, fs::Permissions::from_mode(0o755)) + .map_err(|error| failed(error.to_string(), false))?; + let new = version_of(&fresh)?; + if new == old { + return Ok(None); + } + fs::rename(&fresh, exe).map_err(|error| { + failed(format!("Nahrazení programu {} se nepodařilo: {error}", exe.display()), true) + })?; + Ok(Some(new)) +} + +fn version_of(binary: &Path) -> Result<String, Failed> { + let text = + tool("shellgame", Command::new(binary).arg("--version"), "Stažený program nelze spustit")?; + match text.lines().next().map(str::trim) { + Some(line) if !line.is_empty() => Ok(line.to_owned()), + _ => Err(failed("Stažený program nelze spustit: `--version` nic nevypsalo".into(), false)), + } +} diff --git a/src/shell/bash.sh b/src/shell/bash.sh new file mode 100644 index 0000000..fe32af4 --- /dev/null +++ b/src/shell/bash.sh @@ -0,0 +1,134 @@ +# shellcheck shell=bash +# ShellGame integration for bash: `bash --rcfile <this file> -i`; the contract is in src/shell/mod.rs. +# Must stay bash 3.2 compatible (no associative arrays, mapfile, ${x,,}, &>>, |&). Functions use +# `cmd || rc=$?`, not `cmd; rc=$?`, so they keep working when the player has run `set -e`. + +# Features that would interfere with the game. +unset PROMPT_COMMAND +set +o histexpand +shopt -u checkwinsize 2>/dev/null || true +shopt -s globasciiranges 2>/dev/null || true + +__shellgame_decode() { + printf '%s' "$1" | base64 --decode +} + +# Runs one directive: <stderr file> <version> <verb> [<base64 argument>...]. A closed set, never eval. +__shellgame_eval() { + local stderr_file="$1" version="${2-}" verb="${3-}" name value + [ "$#" -ge 3 ] && [ "$version" = v1 ] || return 2 + shift 3 + case "$verb:$#" in + cd:1) + value="$(__shellgame_decode "$1")" || return 2 + builtin cd -- "$value" || return + ;; + export:2) + name="$(__shellgame_decode "$1")" || return 2 + value="$(__shellgame_decode "$2")" || return 2 + [[ "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || return 2 + export "$name=$value" + ;; + exit:0) + command rm -f "$stderr_file" + builtin exit 0 + ;; + *) + return 2 + ;; + esac +} + +# Records that the player used `pwd`, for the levels that ask for it. The workspace is only ever +# supplied by the game; never guess it. +pwd() { + local rc=0 + builtin pwd "$@" || rc=$? + [ "$rc" -eq 0 ] && [ -d "${SHELLGAME_WORKSPACE:-}" ] && command touch "$SHELLGAME_WORKSPACE/.pwd_used" + return "$rc" +} + +# The one `cd` hook for every level: the binary decides. Status 3 blocks; any other status, a crash +# or a missing binary allows the move, so a broken hook can never lock the player in. +cd() { + local before="$PWD" rc=0 + "$SHELLGAME_BIN" cd-hook pre "$before" "${OLDPWD:-}" -- "$@" || rc=$? + [ "$rc" -eq 3 ] && return 1 + rc=0 + builtin cd "$@" || rc=$? + [ "$rc" -eq 0 ] && { "$SHELLGAME_BIN" cd-hook post "$before" "$PWD" -- "$@" >/dev/null 2>&1 || true; } + return "$rc" +} + +# A typed chmod outside /tmp is refused (status 3); anything else, also a broken binary, runs it. +chmod() { + local rc=0 + command "$SHELLGAME_BIN" guard -- "$@" || rc=$? + [ "$rc" -eq 3 ] && return 1 + command chmod "$@" +} + +# Runs the binary with stderr captured, then replays it: directive lines are executed, the rest is +# passed on unchanged. The arguments come from the player, so shellcheck sees none. +# shellcheck disable=SC2120 +shellgame() { + local stderr_file line payload cr exit_code=0 + local -a words + # A carriage return made by printf: a literal one would garble `type shellgame` on screen. + printf -v cr '\r' + stderr_file="$(command mktemp)" || return 1 + + "$SHELLGAME_BIN" "$@" 2>"$stderr_file" || exit_code=$? + + # `exit` removes the file while it is read; the open descriptor stays valid. + # shellcheck disable=SC2094 + while IFS= read -r line || [ -n "$line" ]; do + line="${line%"$cr"}" # exactly one CR; stderr is otherwise replayed as it is + if [[ "$line" == __SHELLGAME_EXEC__* ]]; then + # Split on single spaces, never evaluate. The sentinel space keeps an empty last argument. + payload="${line#__SHELLGAME_EXEC__} " + words=() + while [ -n "$payload" ]; do + words[${#words[@]}]="${payload%% *}" + payload="${payload#* }" + done + __shellgame_eval "$stderr_file" "${words[@]}" || true + else + printf '%s\n' "$line" >&2 + fi + done < "$stderr_file" + + command rm -f "$stderr_file" + return "$exit_code" +} + +# Sets COMPREPLY to the lines a command prints, one word each, spaces kept (bash 3.2 has no mapfile). +__shellgame_reply() { + local word + COMPREPLY=() + while IFS= read -r word; do COMPREPLY+=("$word"); done < <("$@") +} + +_shellgame_completions() { + local cur="${COMP_WORDS[COMP_CWORD]}" opts="--help" + if [ "$COMP_CWORD" -eq 1 ]; then + case "$cur" in + -*) ;; + *) opts="hint submit status reset show levels resume remove exit update" ;; + esac + elif [ "${COMP_WORDS[1]}" = submit ]; then + __shellgame_reply compgen -f -- "$cur" + return 0 + fi + __shellgame_reply compgen -W "$opts" -- "$cur" +} +complete -F _shellgame_completions shellgame + +# Child bash processes can call `shellgame` too (SHELLGAME_BIN is exported by the launcher). +export -f __shellgame_decode __shellgame_eval shellgame + +# Autostart: last, so that everything above is defined. +if [ -z "${SHELLGAME_AUTO_STARTED:-}" ]; then + export SHELLGAME_AUTO_STARTED=1 + shellgame +fi diff --git a/src/shell/detect.rs b/src/shell/detect.rs new file mode 100644 index 0000000..4f89543 --- /dev/null +++ b/src/shell/detect.rs @@ -0,0 +1,118 @@ +//! Which shell the game starts: the `--shell` override, else the nearest bash, fish or zsh among the +//! parent processes (this sees through `make`, `sudo`, ...), else the basename of `$SHELL`. Only bash +//! and fish are supported, so zsh and anything unknown mean bash. + +use std::ffi::OsStr; +use std::fmt; +use std::fs; +use std::process::Command; + +/// The shells the game can run in. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ShellKind { + Bash, + Fish, +} + +impl ShellKind { + /// `bash` or `fish`, ignoring case. + pub fn from_name(name: &str) -> Option<Self> { + match name.to_ascii_lowercase().as_str() { + "bash" => Some(Self::Bash), + "fish" => Some(Self::Fish), + _ => None, + } + } + + /// The name, which is also the executable that is started. + pub fn name(self) -> &'static str { + match self { + Self::Bash => "bash", + Self::Fish => "fish", + } + } + + /// The integration script that is sourced into the game shell. It is static: everything that + /// varies reaches the shell through the environment, so nothing is ever quoted into script text. + pub fn script(self) -> &'static str { + match self { + Self::Bash => include_str!("bash.sh"), + Self::Fish => include_str!("fish.fish"), + } + } +} + +impl fmt::Display for ShellKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.name()) + } +} + +/// Picks the shell. `forced` is `--shell` (clap has checked it), `env_shell` is `$SHELL`. +pub fn detect(forced: Option<&str>, env_shell: Option<&OsStr>) -> ShellKind { + forced.and_then(ShellKind::from_name).unwrap_or_else(|| { + // zsh is recognised only so that the walk stops there: a player in zsh must not get the + // fish that started that zsh. + let seen = ancestor_shell() + .or_else(|| env_shell.and_then(|shell| shell_name(&shell.to_string_lossy()))); + ShellKind::from_name(seen.unwrap_or_default()).unwrap_or(ShellKind::Bash) + }) +} + +/// `bash`, `fish` or `zsh` for a process name, a path or a login-shell name such as `-zsh`. +fn shell_name(comm: &str) -> Option<&'static str> { + let base = comm.trim().rsplit('/').next().unwrap_or_default(); + let base = base.strip_prefix('-').unwrap_or(base); + ["bash", "fish", "zsh"].into_iter().find(|name| name.eq_ignore_ascii_case(base)) +} + +/// The nearest shell among the parent processes, looking at most 25 levels up. +fn ancestor_shell() -> Option<&'static str> { + let mut pid = std::os::unix::process::parent_id(); + for _ in 0..25 { + let (parent, comm) = parent_and_name(pid)?; + if let Some(shell) = shell_name(&comm) { + return Some(shell); + } + // pid 0 and 1 are the kernel and init; a process that is its own parent ends a cycle. + if parent <= 1 || parent == pid { + return None; + } + pid = parent; + } + None +} + +/// The parent pid and executable name of `pid`: from `/proc` on Linux, from `ps` elsewhere (and in +/// sandboxes without a readable `/proc`). +fn parent_and_name(pid: u32) -> Option<(u32, String)> { + let from_proc = || { + let comm = fs::read_to_string(format!("/proc/{pid}/comm")).ok()?; + let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; + // `pid (name) state ppid ...`: the name may contain spaces and parentheses. + let parent = stat.rsplit_once(')')?.1.split_whitespace().nth(1)?; + Some((parent.parse().ok()?, comm.trim().to_owned())) + }; + from_proc().or_else(|| { + let output = + Command::new("ps").args(["-o", "ppid=,comm=", "-p", &pid.to_string()]).output().ok()?; + // `comm` may contain spaces, so only the first word is the pid. + let text = String::from_utf8_lossy(&output.stdout).into_owned(); + let (parent, comm) = text.lines().next()?.trim_start().split_once(char::is_whitespace)?; + Some((parent.parse().ok()?, comm.trim().to_owned())) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shells_are_recognised_by_name_path_or_login_name() { + assert_eq!(shell_name("-zsh"), Some("zsh")); + assert_eq!(shell_name("/opt/homebrew/bin/Fish"), Some("fish")); + assert_eq!(shell_name("bash"), Some("bash")); + assert_eq!(shell_name("bash-wrapper"), None); + assert_eq!(detect(Some("FISH"), None), ShellKind::Fish); + } +} diff --git a/src/shell/fish.fish b/src/shell/fish.fish new file mode 100644 index 0000000..16c3bed --- /dev/null +++ b/src/shell/fish.fish @@ -0,0 +1,104 @@ +# ShellGame integration for fish: `fish --no-config --init-command '...; source $SHELLGAME_INIT'`; the +# contract is in src/shell/mod.rs. Must stay fish 3.3 compatible: nothing from fish 3.4 or 4 (`$(...)`). + +function __shellgame_decode + printf '%s' "$argv[1]" | command base64 --decode +end + +# fish's own `cd` keeps the directory history (`cd -`, prevd, nextd). Keep a copy before the hook +# below replaces it; teleports and the hook call the copy. The guard is for a second `source`. +if not functions -q __shellgame_cd + functions --copy cd __shellgame_cd +end + +# Runs one directive: <stderr file> <version> <verb> [<base64 argument>...]. A closed set, never eval. +function __shellgame_eval + set -l stderr_file $argv[1] + set -e argv[1] + switch "$argv[1] $argv[2] "(count $argv) + case 'v1 cd 3' + set -l destination (__shellgame_decode "$argv[3]"); or return 2 + __shellgame_cd -- "$destination" + case 'v1 export 4' + set -l name (__shellgame_decode "$argv[3]"); or return 2 + set -l value (__shellgame_decode "$argv[4]"); or return 2 + string match -rq '^[A-Za-z_][A-Za-z0-9_]*$' -- "$name"; or return 2 + set -gx $name "$value" + case 'v1 exit 2' + command rm -f $stderr_file + exit 0 + case '*' + return 2 + end +end + +# Records that the player used `pwd`, for the levels that ask for it. The workspace is only ever +# supplied by the game; never guess it. +function pwd --description 'Print working directory' + builtin pwd $argv + set -l rc $status + test $rc -eq 0; and test -d "$SHELLGAME_WORKSPACE"; and command touch "$SHELLGAME_WORKSPACE/.pwd_used" + return $rc +end + +# The one `cd` hook for every level: the binary decides. Status 3 blocks; any other status, a crash +# or a missing binary allows the move, so a broken hook can never lock the player in. +function cd --description 'Change directory' + set -l before $PWD + # fish never sets OLDPWD: `cd -` goes to the newest entry of dirprev, or of dirnext right after a + # `cd -` (the history toggles direction). Empty when unknown. + set -l prev '' + if test "$__fish_cd_direction" = next + set -q dirnext[-1]; and set prev $dirnext[-1] + else + set -q dirprev[-1]; and set prev $dirprev[-1] + end + command "$SHELLGAME_BIN" cd-hook pre "$before" "$prev" -- $argv + test $status -eq 3; and return 1 + + __shellgame_cd $argv + set -l cd_status $status + test $cd_status -eq 0; and command "$SHELLGAME_BIN" cd-hook post "$before" "$PWD" -- $argv >/dev/null 2>&1 + return $cd_status +end + +# A typed chmod outside /tmp is refused (status 3); anything else, also a broken binary, runs it. +function chmod --wraps chmod --description 'Change file modes' + command "$SHELLGAME_BIN" guard -- $argv + test $status -eq 3; and return 1 + command chmod $argv +end + +# Runs the binary with stderr captured, then replays it: directive lines are executed, the rest is +# passed on unchanged. +function shellgame + set -l stderr_file (command mktemp); or return 1 + + command "$SHELLGAME_BIN" $argv 2>$stderr_file + set -l exit_code $status + + while read -l line + set line (string replace -r '\r$' '' -- "$line") # exactly one CR; stderr is otherwise replayed as it is + if string match -rq '^__SHELLGAME_EXEC__' -- "$line" + # Split on single spaces, never evaluate. + set -l words (string split ' ' -- (string replace -r '^__SHELLGAME_EXEC__' '' -- "$line")) + __shellgame_eval "$stderr_file" $words + else + printf '%s\n' "$line" >&2 + end + end <$stderr_file + + command rm -f $stderr_file + return $exit_code +end + +complete -c shellgame -f +complete -c shellgame -n __fish_use_subcommand -a 'hint submit status reset show levels resume remove exit update' +complete -c shellgame -s h -l help -d 'Zobrazit nápovědu' +complete -c shellgame -n '__fish_seen_subcommand_from submit' -F + +# Autostart: last, so that everything above is defined. +if not set -q SHELLGAME_AUTO_STARTED + set -gx SHELLGAME_AUTO_STARTED 1 + shellgame +end diff --git a/src/shell/launch.rs b/src/shell/launch.rs new file mode 100644 index 0000000..074cde6 --- /dev/null +++ b/src/shell/launch.rs @@ -0,0 +1,62 @@ +//! Starting the game shell: writes the script, optionally confines the shell ([`crate::sandbox`]) +//! and waits for it. Signals are not touched: an interactive shell makes itself the foreground +//! process group, so Ctrl-C reaches it and never this process. + +use std::io; +use std::path::PathBuf; +use std::process::{Command, ExitStatus}; + +use super::{ENV_AUTO_STARTED, ENV_BIN, ENV_INIT, ENV_SANDBOX, ShellKind}; +use crate::{sandbox, state}; + +#[derive(Debug, Clone)] +pub struct LaunchOptions { + pub shell: ShellKind, + /// The game binary, usually `current_exe()`: an absolute path, since the shell runs it from + /// every directory. + pub binary: PathBuf, + /// The game's state directory (absolute): holds the script and stays writable in the sandbox. + pub state_dir: PathBuf, + /// Confine the shell ([`sandbox::confine`]); without Landlock the launch fails rather than start + /// unconfined. The launcher itself is never confined. + pub sandbox: bool, +} + +/// Starts the game shell and waits for it. The shell's own exit status is data, not an error. +/// `configure` may still adjust the command (stdio, extra environment) before the shell starts; +/// tests use it to run the real launch path without a terminal. +pub fn launch_with( + options: &LaunchOptions, + configure: impl FnOnce(&mut Command), +) -> io::Result<ExitStatus> { + // Sourced once at startup; one fixed file per shell, replaced by a rename so that a game shell + // starting at the same time never sees a partial file. In the state directory it is never left + // behind in /tmp when a terminal closes. + let name = format!("init.{}", options.shell.name()); + let init = state::replace(&options.state_dir, &name, options.shell.script().as_bytes())?; + + let mut command = Command::new(options.shell.name()); + match options.shell { + // `--norc` would disable the script and `--noprofile` would drop the player's PATH setup. + ShellKind::Bash => command.arg("--rcfile").arg(&init).arg("-i"), + // No config files, universal variables or history writes: the sandbox forbids them anyway. + ShellKind::Fish => command.args([ + "--no-config", + "--init-command", + "function fish_greeting; end; source $SHELLGAME_INIT", + ]), + }; + command + .env(ENV_BIN, &options.binary) + .env(ENV_INIT, &init) + .env(ENV_SANDBOX, if options.sandbox { "landlock" } else { "off" }) + .env("TMPDIR", "/tmp") + .env("BASH_SILENCE_DEPRECATION_WARNING", "1") + // Even inside another game shell the new one must autostart. + .env_remove(ENV_AUTO_STARTED); + if options.sandbox { + sandbox::confine(&mut command, &options.state_dir)?; + } + configure(&mut command); + command.status() +} diff --git a/src/shell/mod.rs b/src/shell/mod.rs new file mode 100644 index 0000000..bedd3ba --- /dev/null +++ b/src/shell/mod.rs @@ -0,0 +1,97 @@ +//! The game shell: how the game runs inside a wrapped bash or fish and changes that shell. +//! +//! A process cannot change the directory or environment of its parent, so the game starts a *game +//! shell* of its own ([`launch_with`](fn@launch_with), confined by [`crate::sandbox`]) and sources +//! an integration script into it (`bash.sh`, `fish.fish`; [`ShellKind::script`]). The scripts hold no +//! game logic and know no paths: they forward events to the binary and replay what it asks for. +//! +//! # Environment +//! +//! Set by the launcher, never pasted into script text. An inherited `SHELLGAME_AUTO_STARTED` is +//! removed, so the new shell autostarts the game; the scripts export it as `1` when they do. +//! +//! | variable | value | +//! |------------------------------------|--------------------------------------------------| +//! | `SHELLGAME_BIN` | absolute path of the binary | +//! | `SHELLGAME_INIT` | the script, `<state dir>/init.<shell>` | +//! | `SHELLGAME_SANDBOX` | `landlock` or `off`: is the shell confined | +//! | `SHELLGAME_PENDING` | a command typed outside the game shell, see below | +//! | `TMPDIR` | `/tmp`, the writable temporary directory | +//! | `BASH_SILENCE_DEPRECATION_WARNING` | `1`: no zsh banner from the macOS bash 3.2 | +//! +//! `SHELLGAME_PENDING` is the subcommand and its arguments joined by U+001F (`resume`, `3.2`), set +//! when the player typed `shellgame resume 3.2`, `reset` or `submit ...` outside the game shell. The +//! autostart's bare `shellgame` finds it, empties it with an `export` directive (so it runs once) +//! and runs that command as if it had been typed there. +//! +//! The binary takes `SHELLGAME_SANDBOX` as the sign that it runs inside the game shell, where its +//! directives are replayed. It exports `SHELLGAME_WORKSPACE` (where evidence markers live) itself. +//! +//! # The `shellgame` function and directives +//! +//! `shellgame ARGS...` runs `"$SHELLGAME_BIN" ARGS...` with stdin and stdout on the terminal and +//! stderr in a temporary file, replays that file line by line and returns the binary's status. A +//! line that starts with the marker is a directive and is run; every other line goes to stderr +//! unchanged, a marker anywhere else in a line is plain text. No command name is special: +//! `shellgame remove` closes the shell with the `exit` directive. +//! +//! ```text +//! __SHELLGAME_EXEC__v1 <verb>[ <base64 argument>...] +//! ``` +//! +//! | verb | arguments | effect | +//! |----------|-------------|------------------------------------------------------------------| +//! | `cd` | destination | the shell's own `cd`: never the hook below, so teleports are free | +//! | `export` | name, value | set and export one variable (`^[A-Za-z_][A-Za-z0-9_]*$`) | +//! | `exit` | none | close the game shell | +//! +//! Arguments are standard padded base64 of the raw bytes, so no metacharacter reaches a shell +//! parser; lines end in LF (one CR is tolerated). Unknown versions, verbs and argument counts are +//! ignored, and nothing is ever evaluated. +//! +//! # The `cd` hook +//! +//! One static `cd` function, no per-level list. Before moving it runs the binary directly (not +//! through `shellgame`: stderr stays live): +//! +//! ```text +//! "$SHELLGAME_BIN" cd-hook pre <pwd> <prev> -- <cd args...> +//! ``` +//! +//! `<prev>` is where `cd -` would go (`$OLDPWD`; fish never sets it, so the newest entry of +//! `$dirprev`, or of `$dirnext` right after a `cd -`), empty when unknown. `<cd args...>` are the +//! arguments of the `cd` call verbatim (none, `-`, `--`, an empty string, a directory named `pre`), +//! so the binary parses this line by hand. Exit status **3** blocks: the function returns 1 without +//! moving, the binary having explained on stderr. Any other status, a crash or a missing binary +//! allows the move, so a broken hook never locks the player in. After a successful move it runs +//! `cd-hook post <old pwd> <new pwd> -- <cd args...>` with its output discarded and status ignored. +//! +//! # The other hooks +//! +//! * **`pwd`** is `builtin pwd` plus `touch "$SHELLGAME_WORKSPACE/.pwd_used"` after a success, only +//! when that variable names an existing directory. The workspace is never guessed. +//! * **`chmod`** first runs `"$SHELLGAME_BIN" guard -- <args...>`. Status 3 refuses (nothing runs, +//! return 1, the binary explained), anything else runs the real `chmod`. Only a typed `chmod` is +//! guarded, not scripts or `command chmod`. +//! +//! # Shells +//! +//! bash 3.2 (the macOS system bash) and later, started as `bash --rcfile <script> -i`; the function +//! `shellgame` and its helpers are exported to child bash processes. The functions survive a +//! player's `set -e`. fish 3.3 and later with nothing from 3.4 or 4 (no `$(...)`), started as `fish +//! --no-config`. `tests/shell.rs` runs both scripts in real shells. + +pub mod detect; +pub mod launch; +pub mod protocol; + +pub use detect::{ShellKind, detect}; +pub use launch::{LaunchOptions, launch_with}; +pub use protocol::Directives; + +pub const ENV_BIN: &str = "SHELLGAME_BIN"; +pub const ENV_INIT: &str = "SHELLGAME_INIT"; +pub const ENV_SANDBOX: &str = "SHELLGAME_SANDBOX"; +pub const ENV_AUTO_STARTED: &str = "SHELLGAME_AUTO_STARTED"; +pub const ENV_PENDING: &str = "SHELLGAME_PENDING"; +pub const ENV_WORKSPACE: &str = "SHELLGAME_WORKSPACE"; diff --git a/src/shell/protocol.rs b/src/shell/protocol.rs new file mode 100644 index 0000000..a7ca415 --- /dev/null +++ b/src/shell/protocol.rs @@ -0,0 +1,60 @@ +//! The directives the binary writes for the `shellgame` shell function (wire format: [`super`]). + +use std::ffi::OsStr; +use std::io::{self, Write}; +use std::os::unix::ffi::OsStrExt; +use std::path::Path; + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; + +/// One directive line without its newline: `__SHELLGAME_EXEC__v1 <verb> <base64 argument>...`. +/// +/// ``` +/// use shellgame::shell::protocol::encode; +/// +/// assert_eq!(encode("export", &["NAME", "value"]), "__SHELLGAME_EXEC__v1 export TkFNRQ== dmFsdWU="); +/// assert_eq!(encode::<&str>("exit", &[]), "__SHELLGAME_EXEC__v1 exit"); +/// ``` +pub fn encode<A: AsRef<[u8]>>(verb: &str, args: &[A]) -> String { + let mut line = format!("__SHELLGAME_EXEC__v1 {verb}"); + for arg in args { + line.push(' '); + STANDARD.encode_string(arg, &mut line); + } + line +} + +/// Writes directives to stderr, or nothing outside the game shell, where nobody would replay them. +#[derive(Debug, Clone, Copy, Default)] +pub struct Directives { + enabled: bool, +} + +impl Directives { + pub fn new(enabled: bool) -> Self { + Self { enabled } + } + + /// Change directory like a teleport: the shell's own `cd`, never the hook. + pub fn cd(&self, path: impl AsRef<Path>) { + self.emit("cd", &[path.as_ref().as_os_str().as_bytes()]); + } + + /// Set and export `name=value`; the scripts drop a name that is not `[A-Za-z_][A-Za-z0-9_]*`. + pub fn export(&self, name: &str, value: impl AsRef<OsStr>) { + self.emit("export", &[name.as_bytes(), value.as_ref().as_bytes()]); + } + + pub fn exit(&self) { + self.emit::<&str>("exit", &[]); + } + + /// One `write`, so concurrent writers cannot split a line. Errors are ignored: a closed stderr + /// leaves the shell unchanged, which is the only sensible outcome. + fn emit<A: AsRef<[u8]>>(&self, verb: &str, args: &[A]) { + if self.enabled { + let _ = io::stderr().write_all((encode(verb, args) + "\n").as_bytes()); + } + } +} diff --git a/src/shellgame/__init__.py b/src/shellgame/__init__.py deleted file mode 100644 index 3dc1f76..0000000 --- a/src/shellgame/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__version__ = "0.1.0" diff --git a/src/shellgame/__main__.py b/src/shellgame/__main__.py deleted file mode 100644 index 1468a15..0000000 --- a/src/shellgame/__main__.py +++ /dev/null @@ -1,6 +0,0 @@ -import sys - -from shellgame.cli.commands import cli - -if __name__ == "__main__": - sys.exit(cli()) diff --git a/src/shellgame/cli/__init__.py b/src/shellgame/cli/__init__.py deleted file mode 100644 index 1ea8c50..0000000 --- a/src/shellgame/cli/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""CLI module for ShellGame commands.""" diff --git a/src/shellgame/cli/boot.py b/src/shellgame/cli/boot.py deleted file mode 100644 index 0814118..0000000 --- a/src/shellgame/cli/boot.py +++ /dev/null @@ -1,32 +0,0 @@ -""" -Boot orchestration for ShellGame. - -This module handles the process wrapping and subshell launching logic. -It decides whether to launch a subshell or run the game logic in the current process. -""" - -import os -from dataclasses import dataclass - -from shellgame.cli.subshell import detect_interactive_shell, launch_subshell - - -@dataclass(frozen=True, slots=True) -class BootResult: - should_exit: bool - exit_code: int = 0 - - -def boot_if_needed(*, wrapped: bool, devmode: bool, pending_command: list[str] | None = None) -> BootResult: - if wrapped: - return BootResult(should_exit=False) - - forced = (os.environ.get("SHELLGAME_FORCE_SHELL") or "").strip().lower() - if forced in ("bash", "fish"): - target_shell = forced - else: - detected = detect_interactive_shell() - target_shell = detected if detected in ("fish", "bash") else "bash" - - launch_subshell(target_shell, devmode, pending_command=pending_command) - return BootResult(should_exit=True, exit_code=0) diff --git a/src/shellgame/cli/commands.py b/src/shellgame/cli/commands.py deleted file mode 100644 index 798d90d..0000000 --- a/src/shellgame/cli/commands.py +++ /dev/null @@ -1,356 +0,0 @@ -"""CLI commands for ShellGame. - -This module should be a thin Click parsing layer. -Core gameplay orchestration lives in `shellgame.core.session.GameSession`. - -Shell override: -- You can force which subshell ShellGame launches with `--shell bash|fish`. -- Useful when auto-detection is wrong (nested shells / wrappers like `uv` / `make`). -""" - -import os -import sys -from pathlib import Path -from typing import cast - -import click -from rich.console import Console - -from shellgame.cli.boot import boot_if_needed -from shellgame.core.services import GameServices -from shellgame.core.session import GameSession -from shellgame.levels.registry import LevelRegistry, parse_level_id -from shellgame.messages import Messages -from shellgame.paths import current_directory -from shellgame.shell.client import ShellClient -from shellgame.state.manager import StateManager, StatePersistenceError -from shellgame.ui.display import Display - - -class _Services: - instance: GameServices | None = None - - -def _game_services() -> GameServices: - """Create process-wide services on first use, not at import. - - `StateManager` reads XDG paths in its constructor. Binding it at import - time would freeze those paths before tests or playtesting can isolate HOME. - """ - if _Services.instance is None: - _Services.instance = GameServices() - return _Services.instance - - -def __getattr__(name: str) -> object: - if name in {"console", "display", "state_manager", "level_registry", "shell_client", "services"}: - default = _game_services() - if name == "services": - return default - return getattr(default, name) - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") - - -def _module_attr(name: str) -> object: - return getattr(sys.modules[__name__], name) - - -def _teleport_notice(destination: Path) -> None: - cast(Console, _module_attr("console")).print( - f"[yellow]Byli jste [bold]teleportováni[/bold] do adresáře [violet]{destination}[/violet][/yellow]\n" - ) - - -def _get_session() -> GameSession: - return GameSession( - console=cast(Console, _module_attr("console")), - display=cast(Display, _module_attr("display")), - state_manager=cast(StateManager, _module_attr("state_manager")), - level_registry=cast(LevelRegistry, _module_attr("level_registry")), - teleport_notice=_teleport_notice, - shell_client=cast(ShellClient, _module_attr("shell_client")), - workspace_factory=_game_services().get_workspace_manager_factory(), - ) - - -class CzechGroup(click.Group): - def get_help(self, ctx: click.Context) -> str: - help_text = super().get_help(ctx) - replacements = { - "Usage:": "Použití:", - "Options:": "Možnosti:", - "Commands:": "Příkazy:", - "Show this message and exit.": "Zobrazit tuto nápovědu a ukončit.", - } - for eng, cze in replacements.items(): - help_text = help_text.replace(eng, cze) - return help_text - - def invoke(self, ctx: click.Context) -> object: - try: - return super().invoke(ctx) - except StatePersistenceError as exc: - cast(Display, _module_attr("display")).show_state_error(str(exc)) - ctx.exit(1) - except OSError as exc: - message = Messages.FILESYSTEM_ERROR.format(error=exc) - if current_directory() is None: - message = f"{Messages.CWD_MISSING}\n\n{exc}" - cast(Display, _module_attr("display")).show_state_error(message) - ctx.exit(1) - - -def _pending_command(ctx: click.Context) -> list[str]: - """The subcommand this invocation asked for, as argv tokens. - - Running `shellgame <command>` from an ordinary terminal has to launch the - game subshell first, which ends this process before the subcommand ever - runs. The tokens are handed to the subshell so it can run them once it is - up, instead of silently dropping what the player typed. - """ - if ctx.invoked_subcommand is None: - return [] - - argv = sys.argv[1:] - try: - start = argv.index(ctx.invoked_subcommand) - except ValueError: - # The name reaching the group differs from what was typed; forwarding a - # guess would be worse than the plain start the player already gets. - return [] - return argv[start:] - - -#: Commands that only print something. They need no workspace, no `cd` hooks -#: and no game shell, so launching one would trap the player in a subshell they -#: never asked for and have to `exit` out of. -_NO_GAME_SHELL_COMMANDS = frozenset({"levels"}) - - -def _resume_targets_a_real_level(args: list[str]) -> bool: - """Whether `resume` will actually move the player into a level. - - Anything else — no ID, a malformed one, an unknown one, `--help` — only - prints, so it must not drag a subshell up behind it. - """ - if len(args) != 1: - return False - - level_id = args[0] - if parse_level_id(level_id) is None: - return False - return cast(LevelRegistry, _module_attr("level_registry")).get(level_id) is not None - - -def _runs_without_game_shell(ctx: click.Context) -> bool: - command = _pending_command(ctx) - if not command: - return False - - name, *args = command - if name in _NO_GAME_SHELL_COMMANDS: - return True - if name == "resume": - return not _resume_targets_a_real_level(args) - return False - - -@click.group(cls=CzechGroup, invoke_without_command=True) -@click.option("--devmode", is_flag=True, hidden=True, help="Enable developer mode") -@click.option( - "--shell", - "forced_shell", - type=click.Choice(["bash", "fish"], case_sensitive=False), - default=None, - help="Vynutit typ subshellu (bash/fish).", -) -@click.pass_context -def cli(ctx: click.Context, devmode: bool, forced_shell: str | None) -> None: - """ShellGame - Interaktivní výuka navigace v terminálu.""" - ctx.ensure_object(dict) - ctx.obj["devmode"] = devmode - - wrapped = bool(os.environ.get("SHELLGAME_WRAPPER")) - - if forced_shell: - os.environ["SHELLGAME_FORCE_SHELL"] = forced_shell.lower() - - if not wrapped and _runs_without_game_shell(ctx): - # Runs here and the process ends, instead of booting a shell the player - # would then have to leave by hand. - return - - try: - boot = boot_if_needed(wrapped=wrapped, devmode=devmode, pending_command=_pending_command(ctx)) - except Exception as e: - cast(Console, _module_attr("console")).print( - f"[bold red]CHYBA: Nepodařilo se spustit herní shell ({e})[/bold red]" - ) - ctx.exit(1) - - if boot.should_exit: - ctx.exit(boot.exit_code) - - if ctx.invoked_subcommand is None: - _get_session().show_current_level() - - -@cli.command() -def init() -> None: - """Inicializace relace ShellGame (již není potřeba, děje se automaticky).""" - _get_session().init() - - -@cli.command() -@click.option( - "-r", - "--repeat", - is_flag=True, - help="Zobrazit znovu všechny již zobrazené nápovědy pro aktuální level.", -) -def hint(repeat: bool) -> None: - """Zobrazit nápovědu pro aktuální level.""" - _get_session().hint(repeat=repeat) - - -@cli.command(context_settings={"ignore_unknown_options": True}) -@click.argument("answer", required=False) -@click.pass_context -def submit(ctx: click.Context, answer: str | None = None) -> None: - """Odeslat odpověď pro aktuální level.""" - if answer is None and ctx.args: - answer = " ".join(ctx.args) - _get_session().submit(answer) - - -@cli.command() -def status() -> None: - """Zobrazit postup a statistiky.""" - _get_session().status() - - -@cli.command() -def skip() -> None: - """Přeskočit aktuální level (pouze nepovinné levely).""" - _get_session().skip() - - -@cli.command() -def reset() -> None: - """Obnovit strukturu aktuálního levelu.""" - _get_session().reset() - - -@cli.command() -def levels() -> None: - """Vypsat všechny dostupné levely.""" - _get_session().levels() - - -@cli.command() -@click.argument("level_id", required=False) -@click.pass_context -def resume(ctx: click.Context, level_id: str | None) -> None: - """Pokračovat ve hře od zvoleného levelu (např. 1.4).""" - if not _get_session().resume(level_id): - ctx.exit(1) - - -@cli.command() -@click.option( - "--section", - "section_num", - type=int, - default=None, - help="Číslo sekce, kterou chcete zopakovat (např. 1).", -) -@click.option( - "--level", - "level_id", - type=str, - default=None, - help="ID levelu ke zopakování (např. 1.7). Pokud neuvedete, zopakuje se aktuální level.", -) -def repeat(section_num: int | None, level_id: str | None) -> None: - """Znovu zobrazit zadání (aktuálního nebo zvoleného) levelu.""" - _get_session().repeat(section_num=section_num, level_id=level_id) - - -@cli.command(name="show") -@click.option( - "--section", - is_flag=True, - help="Zobrazit znovu úvod aktuální sekce (X.0).", -) -@click.option( - "--level", - is_flag=True, - help="Zobrazit znovu zadání aktuálního levelu.", -) -def show(section: bool, level: bool) -> None: - """Zobrazit znovu zadání aktuálního levelu nebo úvod aktuální sekce.""" - _get_session().show(section=section, level=level) - - -@cli.command() -@click.confirmation_option(prompt="Opravdu chcete odstranit všechna data ShellGame?") -def remove() -> None: - """Smazat stav a pracovní prostor.""" - _get_session().remove() - - -@cli.command() -def exit() -> None: - """Ukončit ShellGame.""" - _get_session().exit() - - -@cli.group(hidden=True) -def dev() -> None: - pass - - -@dev.command(name="jump") -@click.argument("level_id") -def dev_jump(level_id: str) -> None: - _get_session().dev_jump_to(level_id) - - -@dev.command(name="next") -def dev_next() -> None: - _get_session().dev_next() - - -@dev.command(name="prev") -def dev_prev() -> None: - _get_session().dev_previous() - - -@dev.command(name="reload") -def dev_reload() -> None: - _get_session().dev_reload() - - -@dev.command(name="start") -def dev_start() -> None: - _get_session().dev_start() - - -@cli.command(hidden=True, name="cd-hook") -@click.argument("arg1", required=False) -@click.argument("arg2", required=False) -@click.option("--post-move", is_flag=True) -def cd_hook(arg1: str | None, arg2: str | None, post_move: bool) -> None: - if post_move: - target = None - pwd = arg1 - else: - target = arg1 - pwd = arg2 - - _get_session().handle_cd_hook(target=target, pwd=pwd, post_move=post_move) - - -@cli.command(hidden=True, name="fd-hook") -def fd_hook() -> None: - _get_session().handle_fd_hook() diff --git a/src/shellgame/cli/hooks.py b/src/shellgame/cli/hooks.py deleted file mode 100644 index 8a88f47..0000000 --- a/src/shellgame/cli/hooks.py +++ /dev/null @@ -1,111 +0,0 @@ -""" -Shell integration hooks registry + code generators. - -This module is a single source of truth for *which* levels require shell-side -instrumentation (hooks), e.g. wrapping `cd` to enforce navigation constraints. - -The generated wrapper is deliberately identical for every hooked level: it -forwards the attempted move to Python and does nothing else. All decisions live -in the level itself, so adding a hooked level extends one pattern list instead -of emitting another copy of the wrapper. -""" - -from __future__ import annotations - -from shellgame.levels.loader import initialize_levels -from shellgame.levels.registry import get_registry - -_BASH_HOOK_BODY = r""" - # Ask Python whether this move is allowed for the current level. - local target="${1:-}" - if ! shellgame cd-hook "$target" "$PWD"; then - return 1 - fi - - builtin cd "$@" - local rc=$? - - # Report the completed move so the level can track progress. - if [ $rc -eq 0 ]; then - shellgame cd-hook --post-move "$PWD" >/dev/null 2>&1 - fi - - return $rc -""" - -_FISH_HOOK_BODY = r""" - set -l target $argv[1] - - # Ask Python whether this move is allowed for the current level. - if not shellgame cd-hook "$target" "$PWD" - return 1 - end - - builtin cd $argv - set -l rc $status - - # Report the completed move so the level can track progress. - if test $rc -eq 0 - shellgame cd-hook --post-move "$PWD" >/dev/null 2>&1 - end - - return $rc -""" - - -def get_cd_hooked_levels() -> frozenset[str]: - initialize_levels() - return frozenset(str(level.id) for level in get_registry().list_levels() if "cd" in level.hooks) - - -def _sorted_hooked_levels() -> list[str]: - """Hooked level IDs in level order, so the generated script is stable.""" - - def key(level_id: str) -> tuple[int, ...]: - return tuple(int(part) for part in level_id.split(".")) - - return sorted(get_cd_hooked_levels(), key=key) - - -def generate_bash_cd_hooks() -> str: - levels = _sorted_hooked_levels() - if not levels: - return "" - - patterns = "|".join(f'"{level_id}"' for level_id in levels) - return "\n".join( - [ - "# Auto-generated cd hook dispatch (ShellGame)", - "cd() {", - ' case "$SHELLGAME_LEVEL" in', - f" {patterns})", - _BASH_HOOK_BODY, - " ;;", - ' *) builtin cd "$@" ;;', - " esac", - "}", - "", - ] - ) - - -def generate_fish_cd_hooks() -> str: - levels = _sorted_hooked_levels() - if not levels: - return "" - - patterns = " ".join(f'"{level_id}"' for level_id in levels) - return "\n".join( - [ - "# Auto-generated cd hook dispatch (ShellGame)", - "function cd", - ' switch "$SHELLGAME_LEVEL"', - f" case {patterns}", - _FISH_HOOK_BODY, - " case '*'", - " builtin cd $argv", - " end", - "end", - "", - ] - ) diff --git a/src/shellgame/cli/subshell.py b/src/shellgame/cli/subshell.py deleted file mode 100644 index 84aba76..0000000 --- a/src/shellgame/cli/subshell.py +++ /dev/null @@ -1,292 +0,0 @@ -"""Subshell integration (bash/fish) for ShellGame. - -Responsibilities: -- Generate per-shell integration scripts (bash / fish) from templates -- Inject generated shell hooks (e.g. `cd` wrappers) into integration templates -- Launch a wrapped subshell with startup/banner suppression -""" - -from __future__ import annotations - -import os -import shlex -import subprocess -import sys -import tempfile -from importlib import resources -from pathlib import Path -from string import Template - -from shellgame.cli.hooks import generate_bash_cd_hooks, generate_fish_cd_hooks - -DEV_SHORTCUTS = { - "r": "shellgame dev reload", - "n": "shellgame dev next", - "p": "shellgame dev prev", - "j": "shellgame dev jump", - "s": "shellgame dev start", - "e": "shellgame exit", - "g": "shellgame", -} - - -def _generate_bash_aliases(shortcuts: dict[str, str]) -> str: - lines = ["# Dev shortcuts"] - for alias, command in shortcuts.items(): - lines.append(f'alias {alias}="{command}"') - return "\n".join(lines) - - -def _generate_fish_abbrs(shortcuts: dict[str, str]) -> str: - lines = ["# Dev shortcuts"] - for abbr, command in shortcuts.items(): - lines.append(f'abbr -a {abbr} "{command}"') - return "\n".join(lines) - - -def _load_template(relative_path: str) -> str: - package = "shellgame.cli.templates" - return resources.files(package).joinpath(relative_path).read_text(encoding="utf-8") - - -def _quote_for_fish(argument: str) -> str: - """Single-quote an argument for fish. - - fish's single quotes are literal apart from `\\` and `'`, which is a - different rule from POSIX shells, so `shlex.quote` cannot be reused here. - """ - escaped = argument.replace("\\", "\\\\").replace("'", "\\'") - return f"'{escaped}'" - - -def _autostart_command(shell_name: str, pending_command: list[str] | None) -> str: - """Render `pending_command` so the integration's autostart can run it.""" - if not pending_command: - return "" - quote = _quote_for_fish if shell_name == "fish" else shlex.quote - return " ".join(quote(token) for token in pending_command) - - -def _render_template( - template_text: str, - *, - binary_path: str, - dev_shortcuts: str, - cd_hook: str, - autostart_args: str = "", -) -> str: - return Template(template_text).safe_substitute( - binary_path=binary_path, - dev_shortcuts=dev_shortcuts, - cd_hook=cd_hook, - autostart_args=autostart_args, - ) - - -def _read_proc_comm(pid: int) -> str | None: - try: - with open(f"/proc/{pid}/comm") as f: - return f.read().strip().lower() - except OSError: - return None - - -def detect_interactive_shell() -> str: - zero = (os.environ.get("0") or "").lower() - for shell in ("fish", "bash", "zsh"): - if shell in zero: - return shell - - parent = get_parent_shell() - if parent in ("fish", "bash", "zsh"): - return parent - - shell_env = (os.environ.get("SHELL") or "").lower() - for shell in ("fish", "bash", "zsh"): - if shell_env.endswith(f"/{shell}") or shell_env == shell: - return shell - - return "unknown" - - -def _read_proc_stat_ppid(pid: int) -> int | None: - try: - raw = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8", errors="replace") - close = raw.rfind(")") - if close == -1: - return None - - tail = raw[close + 1 :].strip() - if not tail: - return None - - fields = tail.split() - if len(fields) < 2: - return None - - return int(fields[1]) - except (OSError, ValueError): - return None - - -def get_parent_shell() -> str: - pid = os.getppid() - for _ in range(25): - comm = _read_proc_comm(pid) or "" - if "fish" in comm: - return "fish" - if "bash" in comm: - return "bash" - if "zsh" in comm: - return "zsh" - - ppid = _read_proc_stat_ppid(pid) - if not ppid or ppid <= 1 or ppid == pid: - break - pid = ppid - - return "unknown" - - -def get_fish_integration(binary_path: str, devmode: bool = False, *, pending_command: list[str] | None = None) -> str: - template_text = _load_template("fish_integration.template") - - dev_shortcuts = "" - if devmode: - dev_shortcuts = _generate_fish_abbrs(DEV_SHORTCUTS) - - cd_hook = generate_fish_cd_hooks() - - return _render_template( - template_text, - binary_path=binary_path, - dev_shortcuts=dev_shortcuts, - cd_hook=cd_hook, - autostart_args=_autostart_command("fish", pending_command), - ) - - -def get_bash_integration(binary_path: str, devmode: bool = False, *, pending_command: list[str] | None = None) -> str: - template_text = _load_template("bash_integration.template") - - dev_shortcuts = "" - if devmode: - dev_shortcuts = _generate_bash_aliases(DEV_SHORTCUTS) - - cd_hook = generate_bash_cd_hooks() - - return _render_template( - template_text, - binary_path=binary_path, - dev_shortcuts=dev_shortcuts, - cd_hook=cd_hook, - autostart_args=_autostart_command("bash", pending_command), - ) - - -def _generate_fish_init_command(script_path: str) -> str: - return f"function fish_greeting; end; source {script_path}" - - -def _get_launcher_argv(devmode: bool) -> list[str]: - argv0 = (sys.argv[0] or "").strip() - if argv0 and argv0 not in ("-c", "-m"): - launcher_argv = [os.path.abspath(argv0)] - if launcher_argv[0].endswith(".py"): - launcher_argv = [sys.executable, launcher_argv[0]] - else: - launcher_argv = [sys.executable, "-m", "shellgame"] - - if devmode: - launcher_argv.append("--devmode") - - return launcher_argv - - -def _create_integration_script( - shell_name: str, binary_path: str, devmode: bool, pending_command: list[str] | None = None -) -> str: - with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=f".{shell_name}") as f: - if shell_name == "fish": - f.write(get_fish_integration(binary_path, devmode, pending_command=pending_command)) - else: - f.write(get_bash_integration(binary_path, devmode, pending_command=pending_command)) - return f.name - - -def _create_fd_hook_script(launcher_argv: list[str]) -> str: - with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".sh") as file: - command = shlex.join([*launcher_argv, "fd-hook"]) - file.write(f"#!/bin/sh\nexec {command}\n") - path = file.name - os.chmod(path, 0o700) - return path - - -def _print_debug_info(shell_name: str, devmode: bool, script_path: str) -> None: - print(f"[shellgame][debug] launch_subshell(shell_name={shell_name!r}, devmode={devmode})") - print(f"[shellgame][debug] integration_script_path={script_path}") - try: - preview = Path(script_path).read_text(encoding="utf-8", errors="replace") - preview_lines = preview.splitlines() - head = "\n".join(preview_lines[:40]) - print("[shellgame][debug] integration_script_preview (first 40 lines):") - print(head) - except OSError as e: - print(f"[shellgame][debug] integration_script_preview_error={e!r}") - - -def _run_subshell(argv: list[str], env: dict[str, str], debug: bool, label: str) -> None: - if debug: - print(f"[shellgame][debug] {label}_argv={argv!r}") - proc = subprocess.run(argv, check=False, env=env) - if proc.returncode != 0: - raise RuntimeError(f"{label.capitalize()} subshell exited with code {proc.returncode}") - - -def launch_subshell(shell_name: str, devmode: bool = False, *, pending_command: list[str] | None = None) -> None: - launcher_argv = _get_launcher_argv(devmode) - - binary_path = " ".join(shlex.quote(p) for p in launcher_argv) - - debug = (os.environ.get("SHELLGAME_SUBSHELL_DEBUG") or "").strip() == "1" - - script_path = _create_integration_script(shell_name, binary_path, devmode, pending_command) - fd_hook_path: str | None = None - - try: - fd_hook_path = _create_fd_hook_script(launcher_argv) - if debug: - _print_debug_info(shell_name, devmode, script_path) - - env = os.environ.copy() - env["SHELLGAME_WRAPPER"] = "1" - env["SHELLGAME_FD_HOOK"] = fd_hook_path - - # In PyInstaller onefile bundles, LD_LIBRARY_PATH is set to the extraction dir (_MEIPASS). - # We must restore the original LD_LIBRARY_PATH so spawned shells and system binaries - # (like flatpak, git, curl) use system libraries instead of bundled ones. - if "LD_LIBRARY_PATH_ORIG" in env: - env["LD_LIBRARY_PATH"] = env.pop("LD_LIBRARY_PATH_ORIG") - else: - env.pop("LD_LIBRARY_PATH", None) - if "DYLD_LIBRARY_PATH_ORIG" in env: - env["DYLD_LIBRARY_PATH"] = env.pop("DYLD_LIBRARY_PATH_ORIG") - else: - env.pop("DYLD_LIBRARY_PATH", None) - - if shell_name == "fish": - _run_subshell( - ["fish", "--init-command", _generate_fish_init_command(script_path)], - env, - debug, - "fish", - ) - elif shell_name == "bash": - _run_subshell(["bash", "--rcfile", script_path, "-i"], env, debug, "bash") - else: - raise ValueError(f"Nepodporovaný shell: {shell_name}") - finally: - os.unlink(script_path) - if fd_hook_path is not None: - os.unlink(fd_hook_path) diff --git a/src/shellgame/cli/templates/bash_integration.template b/src/shellgame/cli/templates/bash_integration.template deleted file mode 100644 index 2f41b51..0000000 --- a/src/shellgame/cli/templates/bash_integration.template +++ /dev/null @@ -1,226 +0,0 @@ -# ShellGame shell integration for Bash -# SHELLGAME_WRAPPER is set in the environment by the launcher - -# Disable shell features that can interfere with the game. -unset PROMPT_COMMAND -set +o histexpand -shopt -u checkwinsize 2>/dev/null || true -shopt -s globasciiranges 2>/dev/null || true - -# `$binary_path` is injected by Python. It may contain spaces and/or multiple argv parts. -# Store it as a bash argv array so we can execute it reliably (without fragile splitting). -declare -a SHELLGAME_BINARY_ARR -eval "SHELLGAME_BINARY_ARR=($binary_path)" - -# Command forwarded from outside the game shell; empty for a plain launch. -# Python injects already-quoted tokens, which are valid bash source here, so -# this must NOT go through eval: the quoting would be parsed a second time and -# arguments containing quotes would lose them. -declare -a SHELLGAME_AUTOSTART_ARR -SHELLGAME_AUTOSTART_ARR=($autostart_args) - -# Ensure SHELLGAME_FD_HOOK exists so scripts (e.g. buggy.sh) can invoke fd-hook directly -if [ -z "${SHELLGAME_FD_HOOK:-}" ] || [ ! -x "${SHELLGAME_FD_HOOK:-}" ]; then - _sg_fd_hook="$(mktemp "${TMPDIR:-/tmp}/sg-fd-hook-XXXXXX.sh" 2>/dev/null || mktemp /tmp/sg-fd-hook-XXXXXX.sh)" - printf '#!/bin/sh\nexec %s fd-hook "$@"\n' "$binary_path" > "$_sg_fd_hook" - chmod 700 "$_sg_fd_hook" - export SHELLGAME_FD_HOOK="$_sg_fd_hook" - trap '[ -n "${_sg_fd_hook:-}" ] && rm -f "$_sg_fd_hook"' EXIT -fi - -$dev_shortcuts - -# Decode one base64-encoded protocol argument. -__shellgame_decode() { - printf '%s' "$1" | base64 --decode -} - -# Execute one closed protocol operation. -# Uses `builtin cd` to bypass user-facing cd hooks when the game teleports. -__shellgame_eval() { - local stderr_file="$1" - shift - local version="${1:-}" - local command="${2:-}" - - if [ "$version" != "v1" ]; then - return 2 - fi - shift 2 - - case "$command" in - cd) - [ "$#" -eq 1 ] || return 2 - local destination - destination="$(__shellgame_decode "$1")" || return 2 - builtin cd -- "$destination" - ;; - export) - [ "$#" -eq 2 ] || return 2 - local name value - name="$(__shellgame_decode "$1")" || return 2 - value="$(__shellgame_decode "$2")" || return 2 - [[ "$name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || return 2 - printf -v "$name" '%s' "$value" - export "$name" - ;; - echo) - [ "$#" -eq 1 ] || return 2 - __shellgame_decode "$1" - printf '\n' - ;; - pwd) - [ "$#" -eq 0 ] || return 2 - command pwd - ;; - exit) - [ "$#" -eq 0 ] || return 2 - rm -f "$stderr_file" - [ -n "${_sg_fd_hook:-}" ] && rm -f "$_sg_fd_hook" - builtin exit 0 - ;; - *) - return 2 - ;; - esac -} - -pwd() { - command pwd - # Record usage for game verification. - # The workspace is only ever supplied by the game; never guess it. - if [ -n "${SHELLGAME_WORKSPACE:-}" ] && [ -d "$SHELLGAME_WORKSPACE" ]; then - touch "$SHELLGAME_WORKSPACE/.pwd_used" - fi -} - -# Level-specific `cd` wrapper hooks are injected here (generated from Python). -# The injected code must define a `cd()` function. -# If it does not, we provide a safe default `cd()` below. -$cd_hook - -# Default cd implementation (used when no hook is injected). -if ! declare -F cd >/dev/null 2>&1; then -cd() { - builtin cd "$@" -} -fi - -shellgame() { - local stderr_file - stderr_file="$(mktemp)" - - # Detect if this is a remove command - we'll need to exit the subshell after success. - local is_remove_cmd=0 - if [ "${1:-}" = "remove" ]; then - is_remove_cmd=1 - fi - - # If the current working directory was deleted, relocate before running Python - if [ ! -d "$PWD" ]; then - if [ -n "${SHELLGAME_WORKSPACE:-}" ] && [ -d "$SHELLGAME_WORKSPACE" ]; then - builtin cd -- "$SHELLGAME_WORKSPACE" 2>/dev/null || builtin cd /tmp 2>/dev/null || true - else - builtin cd /tmp 2>/dev/null || true - fi - fi - - # Execute the launcher as argv (array), never via eval/string splitting. - "${SHELLGAME_BINARY_ARR[@]}" "$@" 2>"$stderr_file" - local exit_code=$? - - if [ -f "$stderr_file" ]; then - while IFS= read -r line; do - # Keep exact output except for removing a single trailing CR. - # Do NOT trim generally; it can corrupt legitimate stderr. - line="${line%$'\r'}" - - # Only treat lines that start with the marker as directives. - # This prevents leaking protocol lines to the user. - if [[ "$line" == __SHELLGAME_EXEC__* ]]; then - local protocol_payload="${line#__SHELLGAME_EXEC__}" - # Intentional word splitting on the payload, but with globbing - # disabled so a payload can never expand against the filesystem. - set -f - __shellgame_eval "$stderr_file" $protocol_payload - set +f - else - echo "$line" >&2 - fi - done < "$stderr_file" - fi - - rm -f "$stderr_file" - - # If remove succeeded, exit the subshell cleanly. - # We do this here (after processing stderr) rather than relying on a protocol directive, - # because Click's confirmation prompt can interfere with stderr capture timing. - if [ "$is_remove_cmd" = "1" ] && [ "$exit_code" = "0" ]; then - [ -n "${_sg_fd_hook:-}" ] && rm -f "$_sg_fd_hook" - builtin exit 0 - fi - - return $exit_code -} - -_shellgame_completions() -{ - local cur subcmd - COMPREPLY=() - cur="${COMP_WORDS[COMP_CWORD]}" - - local commands="init hint submit skip status reset resume levels repeat show remove exit" - - if [ "$COMP_CWORD" -eq 1 ]; then - if [[ "${cur}" == -* ]]; then - COMPREPLY=( $(compgen -W "--help" -- "${cur}") ) - else - COMPREPLY=( $(compgen -W "${commands}" -- "${cur}") ) - fi - return 0 - fi - - subcmd="${COMP_WORDS[1]}" - case "${subcmd}" in - hint) - COMPREPLY=( $(compgen -W "-r --repeat --help" -- "${cur}") ) - ;; - repeat) - COMPREPLY=( $(compgen -W "--section --level --help" -- "${cur}") ) - ;; - show) - COMPREPLY=( $(compgen -W "--section --level --help" -- "${cur}") ) - ;; - submit) - COMPREPLY=( $(compgen -f -- "${cur}") ) - ;; - dev) - if [ "$COMP_CWORD" -eq 2 ]; then - COMPREPLY=( $(compgen -W "jump next prev reload start" -- "${cur}") ) - fi - ;; - *) - ;; - esac -} -complete -F _shellgame_completions shellgame sg g - -# Export the function so it's available in the shell -export -f shellgame - -# Autostart block (runs once when integration is sourced). -# This must be at the end so the shellgame function is already defined. -if [ -z "${SHELLGAME_AUTO_STARTED:-}" ]; then - export SHELLGAME_AUTO_STARTED=1 - # A command typed outside the game shell (e.g. `shellgame resume 6.2`) - # cannot run before this shell exists, so Python injects it here as argv - # and it is replayed once the shell is up. Empty for a plain launch. - if [ "${SHELLGAME_SUBSHELL_DEBUG:-}" = "1" ]; then - echo "[shellgame][debug][bash] autostarting shellgame ${SHELLGAME_AUTOSTART_ARR[*]:-}..." >&2 - set -x - shellgame ${SHELLGAME_AUTOSTART_ARR[@]+"${SHELLGAME_AUTOSTART_ARR[@]}"} - { set +x; } 2>/dev/null || true - else - shellgame ${SHELLGAME_AUTOSTART_ARR[@]+"${SHELLGAME_AUTOSTART_ARR[@]}"} - fi -fi diff --git a/src/shellgame/cli/templates/fish_integration.template b/src/shellgame/cli/templates/fish_integration.template deleted file mode 100644 index 2246875..0000000 --- a/src/shellgame/cli/templates/fish_integration.template +++ /dev/null @@ -1,208 +0,0 @@ -# ShellGame shell integration for Fish -# SHELLGAME_WRAPPER is set in the environment by the launcher - -# Define the binary path -# $binary_path is a space-separated list of quoted strings (e.g. "'python' 'script.py'") -# We set it without quotes so Fish parses it as a list. -set -gx SHELLGAME_BINARY $binary_path - -# Command forwarded from outside the game shell; empty for a plain launch. -set -g SHELLGAME_AUTOSTART_LIST $autostart_args - -# Ensure SHELLGAME_FD_HOOK exists so scripts (e.g. buggy.sh) can invoke fd-hook directly -if not set -q SHELLGAME_FD_HOOK; or not test -x "$SHELLGAME_FD_HOOK" - set -g _sg_fd_hook (mktemp -t sg-fd-hook-XXXXXX.sh 2>/dev/null; or mktemp /tmp/sg-fd-hook-XXXXXX.sh) - printf '#!/bin/sh\nexec %s fd-hook "$@"\n' "$binary_path" > "$_sg_fd_hook" - chmod 700 "$_sg_fd_hook" - set -gx SHELLGAME_FD_HOOK "$_sg_fd_hook" - function __shellgame_cleanup_fd_hook --on-event fish_exit - if set -q _sg_fd_hook; and test -f "$_sg_fd_hook" - command rm -f "$_sg_fd_hook" - end - end -end - -# Track whether we've already autostarted (to avoid double-start). -if not set -q SHELLGAME_AUTO_STARTED - set -gx SHELLGAME_AUTO_STARTED 0 -end - -$dev_shortcuts - -# Decode one base64-encoded protocol argument. -function __shellgame_decode - printf "%s" "$argv[1]" | command base64 --decode -end - -# Execute one closed protocol operation. -# Uses `builtin cd` to bypass user-facing cd hooks when the game teleports. -function __shellgame_eval - set -l stderr_file "$argv[1]" - set -e argv[1] - - if test (count $argv) -lt 2; or test "$argv[1]" != "v1" - return 2 - end - - set -l command_name "$argv[2]" - - if test "$command_name" = "cd"; and test (count $argv) -eq 3 - set -l destination (__shellgame_decode "$argv[3]") - or return 2 - builtin cd -- "$destination" - else if test "$command_name" = "export"; and test (count $argv) -eq 4 - set -l name (__shellgame_decode "$argv[3]") - or return 2 - set -l value (__shellgame_decode "$argv[4]") - or return 2 - - string match -rq '^[A-Za-z_][A-Za-z0-9_]*$' -- "$name" - or return 2 - set -gx $name "$value" - else if test "$command_name" = "echo"; and test (count $argv) -eq 3 - __shellgame_decode "$argv[3]" - printf "\n" - else if test "$command_name" = "pwd"; and test (count $argv) -eq 2 - command pwd - else if test "$command_name" = "exit"; and test (count $argv) -eq 2 - rm -f "$stderr_file" - if set -q _sg_fd_hook; and test -f "$_sg_fd_hook" - command rm -f "$_sg_fd_hook" - end - exit 0 - else - return 2 - end -end - -function pwd - command pwd - # Record usage for game verification. - # The workspace is only ever supplied by the game; never guess it. - if test -n "$SHELLGAME_WORKSPACE" -a -d "$SHELLGAME_WORKSPACE" - touch "$SHELLGAME_WORKSPACE/.pwd_used" - end -end - -# Level-specific `cd` wrapper hooks are injected here (generated from Python). -# The injected code should define a `cd` function. -$cd_hook - -# Default cd implementation (used when no hook is injected). -if not functions -q cd - function cd - builtin cd $argv - end -end - -function shellgame - # Create temp files for stderr processing - set stderr_file (mktemp) - - # Detect if this is a remove command - we'll need to exit the subshell after success. - set -l is_remove_cmd 0 - if test (count $argv) -ge 1; and test "$argv[1]" = "remove" - set is_remove_cmd 1 - end - - # If the current working directory was deleted, relocate before running Python - if not test -d "$PWD" - if set -q SHELLGAME_WORKSPACE; and test -d "$SHELLGAME_WORKSPACE" - builtin cd "$SHELLGAME_WORKSPACE" 2>/dev/null; or builtin cd /tmp 2>/dev/null - else - builtin cd /tmp 2>/dev/null - end - end - - # Run the actual command and capture stderr - # Execute directly as a list (SHELLGAME_BINARY is a list) - $SHELLGAME_BINARY $argv 2>$stderr_file - set exit_code $status - - # Process stderr for shell commands and regular output - if test -f "$stderr_file" - while read -l line - # Keep exact output except for removing a single trailing CR. - # Do NOT trim generally; it can corrupt legitimate stderr. - set line (string replace -r '\r$' '' -- "$line") - - # Anchored minimal protocol: - # - only lines starting exactly with __SHELLGAME_EXEC__ - # - shell command is the remainder of the line - if string match -rq '^__SHELLGAME_EXEC__' -- "$line" - set protocol_payload (string replace -r '^__SHELLGAME_EXEC__' '' -- "$line") - set protocol_words (string split " " -- "$protocol_payload") - __shellgame_eval "$stderr_file" $protocol_words - else - echo "$line" >&2 - end - end < $stderr_file - end - - rm -f $stderr_file - - # If remove succeeded, exit the subshell cleanly. - # We do this here (after processing stderr) rather than relying on a protocol directive, - # because Click's confirmation prompt can interfere with stderr capture timing. - if test $is_remove_cmd -eq 1; and test $exit_code -eq 0 - if set -q _sg_fd_hook; and test -f "$_sg_fd_hook" - command rm -f "$_sg_fd_hook" - end - exit 0 - end - - return $exit_code -end - -# Autocomplete for shellgame -function __fish_shellgame_needs_command - set cmd (commandline -opc) - if test (count $cmd) -eq 1 - return 0 - end - return 1 -end - -# Disable general file completion by default -complete -c shellgame -f - -# Root commands -complete -c shellgame -n "__fish_shellgame_needs_command" -a "init hint submit skip status reset resume levels repeat show remove exit" -complete -c shellgame -s h -l help -d "Zobrazit nápovědu" - -# Options for hint -complete -c shellgame -n "__fish_seen_subcommand_from hint" -s r -l repeat -d "Zobrazit znovu všechny již zobrazené nápovědy" -complete -c shellgame -n "__fish_seen_subcommand_from hint" -s h -l help -d "Zobrazit nápovědu" - -# Options for resume -complete -c shellgame -n "__fish_seen_subcommand_from resume" -s h -l help -d "Zobrazit nápovědu" - -# Options for repeat -complete -c shellgame -n "__fish_seen_subcommand_from repeat" -l section -d "Číslo sekce ke zopakování" -complete -c shellgame -n "__fish_seen_subcommand_from repeat" -l level -d "ID levelu ke zopakování" -complete -c shellgame -n "__fish_seen_subcommand_from repeat" -s h -l help -d "Zobrazit nápovědu" - -# Options for show -complete -c shellgame -n "__fish_seen_subcommand_from show" -l section -d "Zobrazit úvod aktuální sekce" -complete -c shellgame -n "__fish_seen_subcommand_from show" -l level -d "Zobrazit zadání aktuálního levelu" -complete -c shellgame -n "__fish_seen_subcommand_from show" -s h -l help -d "Zobrazit nápovědu" - -# Subcommands for dev -complete -c shellgame -n "__fish_seen_subcommand_from dev" -a "jump next prev reload start" - -# Allow file completion for submit -complete -c shellgame -n "__fish_seen_subcommand_from submit" -F - -# Wrap completions for common aliases -complete -c sg -w shellgame -complete -c g -w shellgame - -# Autostart block (runs once when integration is sourced). -# This must be at the end so the shellgame function is already defined. -if test "$SHELLGAME_AUTO_STARTED" = "0" - set -gx SHELLGAME_AUTO_STARTED 1 - # A command typed outside the game shell (e.g. `shellgame resume 6.2`) - # cannot run before this shell exists, so Python injects it here as a list - # and it is replayed once the shell is up. Empty for a plain launch. - shellgame $SHELLGAME_AUTOSTART_LIST -end diff --git a/src/shellgame/core/navigation.py b/src/shellgame/core/navigation.py deleted file mode 100644 index 936e767..0000000 --- a/src/shellgame/core/navigation.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Navigation manager for player movement and teleportation.""" - -import os -from collections.abc import Callable -from pathlib import Path - -from shellgame.levels.registry import LevelRegistry -from shellgame.paths import current_directory -from shellgame.shell.client import ShellClient -from shellgame.state.manager import GameState - - -class NavigationManager: - def __init__( - self, - level_registry: LevelRegistry, - shell_client: ShellClient, - teleport_notice: Callable[[Path], None], - ) -> None: - self._level_registry = level_registry - self._shell = shell_client - self._teleport_notice = teleport_notice - - def get_level_start_directory(self, level_id: str, workspace: Path) -> Path | None: - level = self._level_registry.get(level_id) - if level: - return level.get_start_directory(workspace) - return None - - def maybe_teleport(self, start_dir: Path | None) -> None: - if not start_dir: - return - - # A missing cwd (the player deleted it) always needs teleporting. - current_dir = current_directory() - target_dir = start_dir.resolve() - - if current_dir != target_dir: - try: - os.chdir(target_dir) - except OSError: - self._shell.cd(start_dir) - return - self._shell.cd(start_dir) - self._teleport_notice(start_dir) - - def ensure_user_in_reasonable_place(self, state: GameState) -> None: - level = self._level_registry.get(state.current_level) - if level is None or not level.enforce_start_directory: - return - - start_dir = level.get_start_directory(state.workspace) - if not start_dir: - return - - self.maybe_teleport(start_dir) diff --git a/src/shellgame/core/progress.py b/src/shellgame/core/progress.py deleted file mode 100644 index b0a9709..0000000 --- a/src/shellgame/core/progress.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Progress tracker for game state business logic.""" - -from datetime import datetime - -from shellgame.state.manager import GameState, LevelCompletion - - -class ProgressTracker: - def record_attempt(self, state: GameState, level_id: str) -> None: - state.level_attempts[level_id] = state.level_attempts.get(level_id, 0) + 1 - - def ensure_level_started(self, state: GameState, level_id: str, now: datetime | None = None) -> bool: - if level_id in state.level_started_at: - return False - state.level_started_at[level_id] = now or datetime.now() - return True - - def record_completion( - self, - state: GameState, - level_id: str, - completed_at: datetime | None = None, - ) -> LevelCompletion: - completed_at = completed_at or datetime.now() - - started_at = state.level_started_at.get(level_id) - time_sec = 0 - if started_at is not None: - delta = completed_at - started_at - time_sec = max(0, int(delta.total_seconds())) - - completion = LevelCompletion( - time_sec=time_sec, - hints=state.level_hints_used.get(level_id, 0), - attempts=state.level_attempts.get(level_id, 0), - completed_at=completed_at, - ) - state.levels_complete[level_id] = completion - return completion - - def get_hint_status(self, state: GameState, level_id: str, total_hints: int) -> int: - revealed = state.level_hints_used.get(level_id, 0) - return max(0, min(revealed, total_hints)) - - def reveal_next_hint(self, state: GameState, level_id: str, total_hints: int) -> int: - revealed = self.get_hint_status(state, level_id, total_hints) - if revealed >= total_hints: - return -1 - - state.level_hints_used[level_id] = revealed + 1 - return revealed diff --git a/src/shellgame/core/services.py b/src/shellgame/core/services.py deleted file mode 100644 index 1957be8..0000000 --- a/src/shellgame/core/services.py +++ /dev/null @@ -1,31 +0,0 @@ -""" -Service container and factories for ShellGame. - -This module centralizes the creation of core services and dependencies. -""" - -from collections.abc import Callable -from pathlib import Path - -from rich.console import Console - -from shellgame.levels.loader import initialize_levels -from shellgame.levels.registry import get_registry -from shellgame.shell.client import ShellClient -from shellgame.state.manager import StateManager -from shellgame.ui.display import Display -from shellgame.workspace.builder import WorkspaceManager - - -class GameServices: - def __init__(self) -> None: - self.console = Console() - self.state_manager = StateManager() - self.level_registry = get_registry() - self.display = Display(self.console) - self.shell_client = ShellClient() - - initialize_levels() - - def get_workspace_manager_factory(self) -> Callable[[Path], WorkspaceManager]: - return WorkspaceManager diff --git a/src/shellgame/core/session.py b/src/shellgame/core/session.py deleted file mode 100644 index e5701eb..0000000 --- a/src/shellgame/core/session.py +++ /dev/null @@ -1,607 +0,0 @@ -""" -Core game session orchestration. - -This module encapsulates "what the game does" (boot + game actions) independently -from Click command parsing. The CLI layer should be a thin wrapper around this. -""" - -from __future__ import annotations - -import os -from collections.abc import Callable -from datetime import datetime -from enum import Enum, auto -from pathlib import Path -from typing import Any, Protocol - -from shellgame.core.navigation import NavigationManager -from shellgame.core.progress import ProgressTracker -from shellgame.levels.base import Level -from shellgame.levels.registry import LevelRegistry, UnknownLevelError, parse_level_id -from shellgame.paths import ContainedPathError -from shellgame.shell.client import ShellClient -from shellgame.state.manager import GameState, LevelCompletion, StateLoadError, StateManager -from shellgame.ui.display import Display -from shellgame.workspace.builder import WorkspaceManager - - -class SupportsPrint(Protocol): - def print(self, *args: Any, **kwargs: Any) -> None: ... - - -class _LevelSync(Enum): - """Outcome of checking a saved `current_level` against the registry.""" - - VALID = auto() - RESYNCED = auto() - UNAVAILABLE = auto() - - -class GameSession: - def __init__( # noqa: PLR0913 - self, - *, - console: SupportsPrint, - display: Display, - state_manager: StateManager, - level_registry: LevelRegistry, - teleport_notice: Callable[[Path], None], - shell_client: ShellClient, - workspace_factory: Callable[[Path], WorkspaceManager], - ) -> None: - self._console = console - self._display = display - self._state_manager = state_manager - self._level_registry = level_registry - self._shell_client = shell_client - self._workspace_factory = workspace_factory - - self._progress_tracker = ProgressTracker() - self._navigation_manager = NavigationManager(level_registry, shell_client, teleport_notice) - - def _get_level(self, level_id: str) -> Level | None: - level = self._level_registry.get(level_id) - if level is None: - self._console.print(f"[red]Chyba: Level {level_id} nenalezen[/red]\n") - return level - - def _export_shell_context(self, *, workspace: Path, level_id: str) -> None: - self._shell_client.export("SHELLGAME_WORKSPACE", str(workspace)) - self._shell_client.export("SHELLGAME_LEVEL", level_id) - - def _initialize_game(self, username: str) -> GameState: - state = self._state_manager.create(username) - - workspace_manager = self._workspace_factory(state.workspace) - workspace_manager.init() - - first_level = self._level_registry.get(state.current_level) - if first_level: - self._prepare_level(state.current_level, state) - - self._state_manager.save(state) - self._display.show_init_success(username, str(state.workspace)) - - start_dir = self._navigation_manager.get_level_start_directory(state.current_level, state.workspace) - if start_dir: - self._navigation_manager.maybe_teleport(start_dir) - self._display.show_current_directory(start_dir) - - return state - - def _auto_init_if_needed(self) -> GameState: - state = self._state_manager.load() - if state: - return state - - username = os.environ.get("USER", "player") - return self._initialize_game(username) - - def _restore_workspace_if_missing(self, state: GameState) -> None: - if state.workspace.exists(): - return - - workspace_manager = self._workspace_factory(state.workspace) - workspace_manager.init() - - current_level = self._level_registry.get(state.current_level) - if current_level: - self._prepare_level(state.current_level, state) - - self._display.show_workspace_restored(state.workspace) - - def _resync_stale_level(self, state: GameState) -> _LevelSync: - """Rescue a save whose `current_level` no longer exists. - - Levels get renumbered between versions. Without this, every gameplay - command would report "level not found" and the only way out would be - `shellgame remove`, which destroys all progress. - """ - if self._level_registry.get(state.current_level) is not None: - return _LevelSync.VALID - - stale_id = state.current_level - resolved_id = self._level_registry.resolve_or_nearest(stale_id) - if resolved_id is None: - self._display.show_no_levels_available() - return _LevelSync.UNAVAILABLE - - self._display.show_level_missing(stale_id, resolved_id) - state.current_level = resolved_id - self._progress_tracker.ensure_level_started(state, level_id=resolved_id) - self._state_manager.save(state) - self._prepare_level(resolved_id, state) - return _LevelSync.RESYNCED - - def _active_state(self) -> GameState | None: - """Shared gameplay preamble: load state and rebuild a missing workspace. - - Every gameplay command needs this. Without it, a player whose `/tmp` was - cleared gets confusing validation failures instead of a rebuild. - """ - state = self._state_manager.load() - if not state: - self._display.show_not_initialized() - return None - - sync = self._resync_stale_level(state) - if sync is _LevelSync.UNAVAILABLE: - return None - if sync is _LevelSync.RESYNCED: - # The in-flight command was aimed at a level that no longer exists. - # Applying it to the replacement level would be wrong, so show the - # new assignment instead. - self.show_current_level() - return None - - self._restore_workspace_if_missing(state) - return state - - def show_current_level(self) -> None: - state = self._auto_init_if_needed() - - if self._handle_finished(state): - return - - if self._resync_stale_level(state) is _LevelSync.UNAVAILABLE: - return - - self._restore_workspace_if_missing(state) - - level = self._get_level(state.current_level) - if level is None: - return - - self._display.show_instructions(level) - - if self._progress_tracker.ensure_level_started(state, level_id=level.id, now=datetime.now()): - self._state_manager.save(state) - - self._export_shell_context(workspace=state.workspace, level_id=level.id) - - self._navigation_manager.ensure_user_in_reasonable_place(state) - - if level.is_intro: - self._display.wait_for_continue() - self.submit(answer=None) - - def init(self) -> None: - existing = self._state_manager.load() - if existing: - self._display.show_already_initialized() - return - - username = os.environ.get("USER", "player") - self._initialize_game(username) - - def status(self) -> None: - state = self._state_manager.load() - if not state: - self._display.show_not_initialized() - return - - self._display.show_status(state) - - def hint(self, *, repeat: bool = False) -> None: - state = self._active_state() - if not state: - return - - if self._handle_finished(state): - return - - level = self._get_level(state.current_level) - if level is None: - return - - hints = list(level.hints) - total_hints = len(hints) - - if total_hints == 0: - self._display.show_no_hints_available() - return - - if repeat: - revealed = self._progress_tracker.get_hint_status(state, level.id, total_hints) - self._display.show_repeated_hints(hints, revealed) - else: - revealed_idx = self._progress_tracker.reveal_next_hint(state, level.id, total_hints) - if revealed_idx == -1: - self._display.show_no_more_hints() - else: - self._state_manager.save(state) - self._display.show_hint(hints[revealed_idx], revealed_idx, total_hints) - - def reset(self) -> None: - state = self._active_state() - if not state: - return - - if self._handle_finished(state): - return - - level = self._get_level(state.current_level) - if level is None: - return - - try: - level.reset(state.workspace) - except ContainedPathError as exc: - self._console.print(f"[red]Chyba při obnově levelu: {exc}[/red]\n") - return - - self._display.show_reset(state.current_level) - self._display.show_instructions(level) - self._export_shell_context(workspace=state.workspace, level_id=level.id) - self._navigation_manager.ensure_user_in_reasonable_place(state) - - def levels(self) -> None: - """List every level a player can resume into.""" - registered = self._level_registry.list_levels() - if not registered: - self._display.show_no_levels_available() - return - - state = self._state_manager.load() - self._display.show_levels( - registered, - current_level=state.current_level if state else None, - completed=set(state.levels_complete) if state else set(), - ) - - def resume(self, level_id: str | None) -> bool: - """Move the player to `level_id` and make it their saved position. - - Unlike `repeat`, which only reprints an assignment, this rewrites - `current_level`, so quitting and launching ShellGame again starts here. - - Returns whether the player was actually moved, so the CLI can exit - non-zero when they were not. - """ - # Missing ID is a usage error, but the IDs are the one thing a player - # cannot guess, so the listing comes with it rather than just a refusal. - if level_id is None: - self._display.show_missing_level_id() - self.levels() - return False - - state = self._state_manager.load() - if not state: - self._display.show_not_initialized() - return False - - # Resolved against the request, never against the saved level: an - # explicit target must win even when the save points at a level that - # this version renumbered away. - if parse_level_id(level_id) is None: - self._display.show_invalid_level_id(level_id) - return False - - if self._level_registry.get(level_id) is None: - self._display.show_unknown_level(level_id) - return False - - self._restore_workspace_if_missing(state) - - if not self._prepare_level(level_id, state): - return False - - state.current_level = level_id - # A finished run makes every gameplay command print the completion - # screen instead. Resuming into a level reopens the run, otherwise the - # player would land on the level but be unable to play it. - state.completed_at = None - self._progress_tracker.ensure_level_started(state, level_id=level_id) - self._state_manager.save(state) - - self._display.show_resumed(level_id) - self.show_current_level() - return True - - def repeat(self, *, section_num: int | None, level_id: str | None) -> None: - state = self._state_manager.load() - if not state: - self._display.show_not_initialized() - return - - resolved: str - if level_id: - resolved = level_id - elif section_num is not None: - resolved = f"{section_num}.0" - else: - resolved = state.current_level - - level = self._get_level(resolved) - if level is None: - return - - self._display.show_instructions(level) - - def show(self, *, section: bool, level: bool) -> None: - state = self._state_manager.load() - if not state: - self._display.show_not_initialized() - return - - if self._handle_finished(state): - return - - if section == level: - self._console.print("[yellow]Použití: shellgame show --level nebo shellgame show --section[/yellow]\n") - return - - target_id = state.current_level - if section: - section_prefix, separator, _ = state.current_level.partition(".") - if not separator or not section_prefix.isdigit(): - self._console.print(f"[red]Chyba: Neplatný formát aktuálního levelu: {state.current_level}[/red]\n") - return - target_id = f"{section_prefix}.0" - - target_level = self._get_level(target_id) - if target_level is not None: - self._display.show_instructions(target_level) - - def remove(self) -> None: - try: - state = self._state_manager.load() - except StateLoadError: - # The state file is unreadable, so the workspace path cannot be read - # from it. Fall back to the default location for this user. - username = os.environ.get("USER", "player") - self._workspace_factory(StateManager.default_workspace(username)).remove() - self._state_manager.remove() - self._display.show_removed() - return - - if not state: - self._display.show_not_initialized() - return - - self._workspace_factory(state.workspace).remove() - self._state_manager.remove() - - self._display.show_removed() - - def submit(self, answer: str | None) -> None: - state = self._active_state() - if not state: - return - - if self._handle_finished(state): - return - - level = self._get_level(state.current_level) - if level is None: - return - - ok, msg = level.validate(answer, state) - - if not ok: - self._progress_tracker.record_attempt(state, level_id=level.id) - self._state_manager.save(state) - self._display.show_failure(msg) - return - - completion = self._progress_tracker.record_completion(state, level_id=level.id, completed_at=datetime.now()) - self._advance(state, level=level, success_message=msg, completion=completion) - - def skip(self) -> None: - state = self._active_state() - if not state: - return - - if self._handle_finished(state): - return - - level = self._get_level(state.current_level) - if level is None: - return - - if not level.is_bonus: - self._display.show_not_skippable(level.id) - return - - self._display.show_skipped(level.id) - self._advance(state, level=level, success_message=None, completion=None) - - def _advance( - self, - state: GameState, - *, - level: Level, - success_message: str | None, - completion: LevelCompletion | None, - ) -> None: - """Move to the next level with exactly one atomic state save.""" - try: - next_level_id = self._level_registry.next_level(level.id) - except UnknownLevelError: - # Never mistake an unregistered ID for "the player finished". - if self._resync_stale_level(state) is _LevelSync.RESYNCED: - self.show_current_level() - return - - if next_level_id is None: - state.completed_at = datetime.now() - self._state_manager.save(state) - if success_message is not None: - self._show_success(success_message, completion) - self._display.show_game_complete(state) - return - - state.current_level = next_level_id - self._progress_tracker.ensure_level_started(state, level_id=next_level_id) - self._state_manager.save(state) - - prepared = self._prepare_level(next_level_id, state) - - if success_message is not None and not level.is_intro: - self._show_success(success_message, completion) - self._display.wait_for_continue() - - if not prepared: - return - - self.show_current_level() - - def _show_success(self, message: str, completion: LevelCompletion | None) -> None: - if completion is None: - self._display.show_success(message) - return - self._display.show_success( - message, - time_sec=completion.time_sec, - hints_used=completion.hints, - attempts=completion.attempts, - ) - - def _prepare_level(self, level_id: str, state: GameState) -> bool: - level = self._level_registry.get(level_id) - if level is None: - return True - - try: - level.prepare(state.workspace) - except (OSError, ValueError) as error: - self._display.show_level_setup_error(level_id, str(error)) - return False - return True - - def _handle_finished(self, state: GameState) -> bool: - if getattr(state, "completed_at", None) is None: - return False - self._display.show_game_complete(state) - return True - - def dev_jump_to(self, level_id: str) -> None: - state = self._state_manager.load() - if not state: - self._display.show_not_initialized() - return - - level = self._level_registry.get(level_id) - if not level: - self._console.print(f"[red]Neznámý level: {level_id}[/red]") - return - - if not self._prepare_level(level_id, state): - return - state.current_level = level_id - self._state_manager.save(state) - self.show_current_level() - - def dev_next(self) -> None: - state = self._state_manager.load() - if not state: - self._display.show_not_initialized() - return - - try: - next_id = self._level_registry.next_level(state.current_level) - except UnknownLevelError: - if self._resync_stale_level(state) is _LevelSync.RESYNCED: - self.show_current_level() - return - if next_id is None: - self._console.print("[yellow]Žádný další level.[/yellow]") - return - self.dev_jump_to(next_id) - - def dev_previous(self) -> None: - state = self._state_manager.load() - if not state: - self._display.show_not_initialized() - return - - previous_id: str | None = None - for registered_level in self._level_registry.list_levels(): - if registered_level.id == state.current_level: - break - previous_id = registered_level.id - - if previous_id is None: - self._console.print("[yellow]Žádný předchozí level.[/yellow]") - return - self.dev_jump_to(previous_id) - - def dev_reload(self) -> None: - state = self._state_manager.load() - if not state: - self._display.show_not_initialized() - return - - level = self._get_level(state.current_level) - if level is None: - return - - level.reset(state.workspace) - self._display.wait_for_continue() - self._display.show_instructions(level) - self._console.print(f"[green]✓ Level {state.current_level} znovu načten.[/green]") - - def dev_start(self) -> None: - levels = self._level_registry.list_levels() - if not levels: - self._console.print("[red]Nejsou registrovány žádné levely.[/red]") - return - self.dev_jump_to(str(levels[0].id)) - - def exit(self) -> None: - self._console.print("[yellow]Ukončuji ShellGame...[/yellow]") - self._shell_client.exit() - - def handle_cd_hook(self, *, target: str | None, pwd: str | None, post_move: bool) -> None: - state = self._state_manager.load() - if not state: - return - - level = self._get_level(state.current_level) - if not level: - return - - hook = level.hooks.get("cd") - if hook: - hook(target=target, pwd=pwd, post_move=post_move, state=state) - - def handle_fd_hook(self) -> None: - state = self._state_manager.load() - if not state: - return - - level = self._get_level(state.current_level) - if level is None: - return - - try: - stdout_target = os.readlink("/proc/self/fd/1") - stderr_target = os.readlink("/proc/self/fd/2") - except OSError: - return - - level.record_fd_evidence( - stdout_target=stdout_target, - stderr_target=stderr_target, - state=state, - ) diff --git a/src/shellgame/levels/__init__.py b/src/shellgame/levels/__init__.py deleted file mode 100644 index d34b918..0000000 --- a/src/shellgame/levels/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Level system module for game progression.""" diff --git a/src/shellgame/levels/base.py b/src/shellgame/levels/base.py deleted file mode 100644 index 61dae8d..0000000 --- a/src/shellgame/levels/base.py +++ /dev/null @@ -1,230 +0,0 @@ -"""Base level interface and abstract class.""" - -import contextlib -import stat -import sys -from collections.abc import Sequence -from pathlib import Path -from textwrap import dedent -from typing import NoReturn, final - -from rich.align import Align -from rich.console import Console -from rich.panel import Panel - -from shellgame.levels.cdpolicy import CdPolicy, CdRequest -from shellgame.levels.completion import Completion -from shellgame.levels.fixture import WorkspaceFixture -from shellgame.levels.solution import Solution -from shellgame.markers import MarkerManager -from shellgame.messages import Messages -from shellgame.paths import WorkspaceRoot, current_directory, resolve_within -from shellgame.protocols import CdHookCallback, GameStateProtocol, ValidationResult - - -def block_cd(level_id: str, message: str) -> NoReturn: - """Reject a `cd` attempt and always tell the player how to recover. - - Strict navigation levels only accept one command shape. Without an explicit - recovery path a player who moves one step too far can be left in a directory - from which every permitted move leads further away. - """ - console = Console(file=sys.stderr, force_terminal=True) - panel = Panel( - Align.center(f"[bold red]{message}[/bold red]", vertical="middle"), - title=f"[bold red]ShellGame ({level_id})[/bold red]", - border_style="red", - style="red", - padding=(1, 4), - ) - console.print(panel) - for line in f"[dim]{Messages.CD_RECOVERY_TIP}[/dim]".splitlines(): - console.print(f" {line}") - console.print() - sys.exit(1) - - -class Level: - id = "" - section: int | None = None - title = "" - instructions = "" - instructions_file: str | None = None - hints: Sequence[str] = () - optional = False - extension = False - start_directory: str | WorkspaceRoot | None = None - reset_markers: Sequence[str] = () - success_message = Messages.CORRECT - is_intro = False - enforce_start_directory = True - section_root = "" - section_fixture: WorkspaceFixture | None = None - fixture: WorkspaceFixture | None = None - completion: Completion | None = None - cd_policy: CdPolicy | None = None - solution: Solution | None = None - - def __init__(self) -> None: - if not self.title: - raise ValueError("Level must have a title") - if not self.is_intro and self.completion is None and type(self).validate is Level.validate: - raise ValueError(f"{type(self).__name__}: regular levels require Completion or a custom validate()") - - self.hints = list(self.hints) - - if self.instructions_file: - content_path = Path(__file__).parent / "content" / self.instructions_file - self.instructions = dedent(content_path.read_text(encoding="utf-8")).strip() - else: - self.instructions = dedent(self.instructions).strip() - - def setup(self, workspace: Path) -> None: - return - - @final - def prepare(self, workspace: Path) -> None: - markers = MarkerManager(workspace) - completion_markers = self.completion.evidence_markers if self.completion else () - marker_names = [name for name in (*self.reset_markers, *completion_markers) if name] - for marker_name in dict.fromkeys(marker_names): - markers.remove(marker_name) - if self.section_root: - target = workspace / self.section_root - if target.exists() and not target.is_dir() and not target.is_symlink(): - with contextlib.suppress(OSError): - target.chmod(stat.S_IRUSR | stat.S_IWUSR) - target.unlink(missing_ok=True) - elif target.is_dir() and not target.is_symlink(): - with contextlib.suppress(OSError): - target.chmod(stat.S_IRWXU) - fixture_root = self.section_path(workspace) - if self.section_fixture: - self.section_fixture.apply(fixture_root) - else: - fixture_root.mkdir(parents=True, exist_ok=True) - if self.fixture: - self.fixture.apply(fixture_root) - self.setup(workspace) - - def section_path(self, workspace: Path) -> Path: - """The directory every path this level declares is resolved against.""" - return resolve_within(workspace, self.section_root, kind="Section root") - - def validate(self, answer: str | None, state: GameStateProtocol) -> ValidationResult: - if self.completion: - return self.completion.validate( - answer, - state, - root=self.section_path(state.workspace), - success_message=self.success_message, - ) - return True, self.success_message - - def reset(self, workspace: Path) -> None: - self.prepare(workspace) - - def get_start_directory(self, workspace: Path) -> Path | None: - if self.start_directory is None: - return None - - if isinstance(self.start_directory, WorkspaceRoot): - return workspace - - return self.section_path(workspace) / self.start_directory - - @property - def is_bonus(self) -> bool: - """Extension and optional levels must never gate advancement.""" - return bool(self.optional or self.extension) - - def returns_to_start(self, *, target: str | None, pwd: str | None, state: GameStateProtocol) -> bool: - """True when this `cd` would bring the player back to the level start directory. - - Strict `cd` hooks use this to always permit the recovery move, so a - player can never be locked out of the level's own workspace. - """ - if not target: - return False - - start = self.get_start_directory(state.workspace) - if start is None: - return False - - candidate = Path(target).expanduser() - if not candidate.is_absolute(): - base = Path(pwd) if pwd else current_directory() - if base is None: - return False - candidate = base / candidate - - try: - return candidate.resolve() == start.resolve() - except (OSError, RuntimeError): - return False - - def cd_enforcement_lifted( - self, - marker: str, - *, - target: str | None, - pwd: str | None, - state: GameStateProtocol, - ) -> bool: - """True when a strict `cd` rule must not block this move. - - Enforcement is lifted once the player has demonstrated the required - command (evidence marker exists) and for any move that returns them to - the level start directory. Without this, a single extra step could leave - a player in a directory from which every permitted move leads away. - """ - if MarkerManager.from_state(state).exists(marker): - return True - return self.returns_to_start(target=target, pwd=pwd, state=state) - - @property - def hooks(self) -> dict[str, CdHookCallback]: - if self.cd_policy is not None: - return {"cd": self._enforce_cd_policy} - return {} - - @final - def _enforce_cd_policy( - self, - *, - target: str | None, - pwd: str | None, - post_move: bool, - state: GameStateProtocol, - ) -> None: - """Apply this level's `cd_policy` under the anti-soft-lock contract. - - The order here is the contract: out-of-scope moves pass silently, - enforcement is always consulted before any rejection, rejections always - go through `block_cd()` so the player is told how to recover, and - evidence is recorded only for a move that satisfied every rule. - """ - policy = self.cd_policy - if policy is None or post_move: - return - - request = CdRequest(target=target, pwd=pwd, state=state, root=self.section_path(state.workspace)) - if not policy.applies(request): - return - - if self.cd_enforcement_lifted(policy.marker, target=target, pwd=pwd, state=state): - return - - if (message := policy.rejection(request)) is not None: - block_cd(self.id, message) - - MarkerManager.from_state(state).create(policy.marker) - - def record_fd_evidence( - self, - *, - stdout_target: str, - stderr_target: str, - state: GameStateProtocol, - ) -> None: - return diff --git a/src/shellgame/levels/cdpolicy.py b/src/shellgame/levels/cdpolicy.py deleted file mode 100644 index 7f2fe1a..0000000 --- a/src/shellgame/levels/cdpolicy.py +++ /dev/null @@ -1,175 +0,0 @@ -"""Declarative `cd` policies for strict navigation levels. - -Several levels teach one exact movement command and must reject anything else. -Hand-written hooks all repeated the same five steps, and each one had to -remember the anti-soft-lock contract: skip post-move calls, consult -`cd_enforcement_lifted()` before rejecting anything, reject only through -`block_cd()`, and record evidence on success. - -A `CdPolicy` states only what the level requires. `Level` performs the -contract, so the guarantee is structural instead of a convention every author -has to re-implement correctly. - -A policy has two kinds of clause: - -* **scope** - decides whether the policy has anything to say about this move. - A move outside the scope is allowed silently. -* **rules** - decide whether an in-scope move is acceptable. The first failing - rule rejects the move with its own message. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from pathlib import Path -from typing import Protocol - -from shellgame.levels.completion import Evidence -from shellgame.markers import MarkerManager -from shellgame.protocols import GameStateProtocol - -#: Placeholder for "this level's own `cd` evidence marker". `Section.level()` -#: replaces it with a name derived from the level ID, so a marker cannot be -#: typo'd or accidentally shared between levels. -CD_EVIDENCE = "\x00shellgame.cd-evidence" - - -def CdEvidence(message: str) -> Evidence: # noqa: N802 - reads as a requirement constructor - """Require that this level's own `cd` drill has been performed. - - The marker name is bound from the level ID at registration time. - """ - return Evidence(CD_EVIDENCE, message) - - -def cd_marker(level_id: str) -> str: - """The evidence marker recording that a level's `cd` drill was performed.""" - return f"cd-{level_id}" - - -def _normalise(command: str | None) -> str | None: - """Compare `cd` targets without tripping over spacing or a trailing slash.""" - if command is None: - return None - stripped = command.strip() - if len(stripped) > 1: - stripped = stripped.rstrip("/") or "/" - return stripped - - -@dataclass(frozen=True, slots=True) -class CdRequest: - """One attempted `cd`, with everything a clause may need to judge it.""" - - target: str | None - pwd: str | None - state: GameStateProtocol - #: the level's section root; every declared path resolves against it - root: Path - - def at(self, path: str) -> bool: - """True when the move starts in the given section-relative directory.""" - if not self.pwd: - return False - expected = self.root / path if path else self.root - try: - return Path(self.pwd).resolve() == expected.resolve() - except (OSError, RuntimeError): - return False - - -class CdScope(Protocol): - def applies(self, request: CdRequest) -> bool: ... - - -class CdRule(Protocol): - def reject(self, request: CdRequest) -> str | None: ... - - -@dataclass(frozen=True, slots=True) -class FromDirectory: - """Restrict the policy to moves that start in one directory.""" - - path: str - - def applies(self, request: CdRequest) -> bool: - return request.at(self.path) - - -@dataclass(frozen=True, slots=True) -class WithTarget: - """Restrict the policy to one literal `cd` argument, such as `-`.""" - - value: str - - def applies(self, request: CdRequest) -> bool: - return request.target == self.value - - -@dataclass(frozen=True, slots=True) -class RequireExactCommand: - """Accept exactly one `cd` argument.""" - - command: str - message: str - - def reject(self, request: CdRequest) -> str | None: - if _normalise(request.target) == _normalise(self.command): - return None - return self.message - - -@dataclass(frozen=True, slots=True) -class RequireAbsolutePath: - """Accept any absolute target, and nothing relative.""" - - message: str - missing_message: str | None = None - - def reject(self, request: CdRequest) -> str | None: - if not request.target: - return self.missing_message or self.message - if request.target.startswith("/"): - return None - return self.message - - -@dataclass(frozen=True, slots=True) -class RequireSourceDirectory: - """Accept the move only when it starts in the given directory.""" - - path: str - message: str - - def reject(self, request: CdRequest) -> str | None: - return None if request.at(self.path) else self.message - - -@dataclass(frozen=True, slots=True) -class RequireEvidence: - """Accept the move only after an earlier drill has been performed.""" - - marker: str - message: str - - def reject(self, request: CdRequest) -> str | None: - if MarkerManager.from_state(request.state).exists(self.marker): - return None - return self.message - - -@dataclass(frozen=True, slots=True) -class CdPolicy: - rules: tuple[CdRule, ...] - scope: tuple[CdScope, ...] = () - #: bound by `Section.level()` from the level ID; never set by hand - marker: str = CD_EVIDENCE - - def applies(self, request: CdRequest) -> bool: - return all(clause.applies(request) for clause in self.scope) - - def rejection(self, request: CdRequest) -> str | None: - for rule in self.rules: - if (message := rule.reject(request)) is not None: - return message - return None diff --git a/src/shellgame/levels/collector.py b/src/shellgame/levels/collector.py deleted file mode 100644 index a044fa0..0000000 --- a/src/shellgame/levels/collector.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Declarative section/level registration helpers.""" - -from __future__ import annotations - -from collections.abc import Callable -from dataclasses import replace -from pathlib import Path -from typing import TypeVar - -from shellgame.levels.base import Level -from shellgame.levels.cdpolicy import CD_EVIDENCE, cd_marker -from shellgame.levels.completion import Evidence, Requirement -from shellgame.levels.fixture import WorkspaceFixture - -L = TypeVar("L", bound=Level) - - -class Section: - def __init__( - self, - number: int, - *, - root: str = "", - fixture: WorkspaceFixture | None = None, - ) -> None: - if number < 0: - raise ValueError("Section number cannot be negative") - root_path = Path(root) - if root_path.is_absolute() or ".." in root_path.parts: - raise ValueError(f"Section root must be workspace-relative: {root!r}") - self._levels: list[Level] = [] - self.number = number - self.root = root - self.fixture = fixture - - def _register(self, number: int, cls: type[L]) -> type[L]: - level_id = f"{self.number}.{number}" - if any(level.id == level_id for level in self._levels): - raise ValueError(f"Duplicate level ID: {level_id}") - cls.id = level_id - cls.section = self.number - cls.section_root = self.root - cls.section_fixture = self.fixture - _bind_cd_evidence(cls, cd_marker(level_id)) - self._levels.append(cls()) - return cls - - @property - def levels(self) -> list[Level]: - return list(self._levels) - - def level(self, number: int) -> Callable[[type[L]], type[L]]: - if number < 0: - raise ValueError("Level number cannot be negative") - return lambda cls: self._register(number, cls) - - -def _bind_cd_evidence(cls: type[Level], marker: str) -> None: - """Give the level's `cd` evidence marker a name derived from its ID. - - Authors write `CdEvidence(...)` and a `CdPolicy` without naming a marker, - so a marker can be neither misspelled nor shared with another level. - """ - if cls.cd_policy is not None and cls.cd_policy.marker == CD_EVIDENCE: - cls.cd_policy = replace(cls.cd_policy, marker=marker) - - completion = cls.completion - if completion is None: - return - - requirements = tuple(_bind_requirement(requirement, marker) for requirement in completion.requirements) - allow_empty_when = ( - _bind_requirement(completion.allow_empty_when, marker) if completion.allow_empty_when is not None else None - ) - if requirements != completion.requirements or allow_empty_when is not completion.allow_empty_when: - cls.completion = replace(completion, requirements=requirements, allow_empty_when=allow_empty_when) - - -def _bind_requirement(requirement: Requirement, marker: str) -> Requirement: - if isinstance(requirement, Evidence) and requirement.marker == CD_EVIDENCE: - return replace(requirement, marker=marker) - return requirement diff --git a/src/shellgame/levels/completion.py b/src/shellgame/levels/completion.py deleted file mode 100644 index c4fdf9e..0000000 --- a/src/shellgame/levels/completion.py +++ /dev/null @@ -1,528 +0,0 @@ -"""Declarative level completion rules.""" - -from __future__ import annotations - -from collections.abc import Mapping -from dataclasses import dataclass, field -from pathlib import Path -from stat import S_IMODE -from typing import Protocol - -from typing_extensions import override - -from shellgame.markers import MarkerManager -from shellgame.messages import Messages -from shellgame.paths import ContainedPathError, current_directory, relative_path, resolve_within -from shellgame.protocols import GameStateProtocol, ValidationResult - -_KIND = "Completion" - - -def _relative_path(value: str) -> Path: - return relative_path(value, kind=_KIND) - - -def _resolve(root: Path, value: str) -> Path: - return resolve_within(root, value, kind=_KIND) - - -def _entry_exists( - root: Path, - path: str, - *, - directory: bool, - should_exist: bool, - error_message: str | None, -) -> ValidationResult: - target = _resolve(root, path) - present = target.exists() and (target.is_dir() if directory else target.is_file()) - if should_exist: - if present: - message = Messages.DIR_EXISTS if directory else Messages.FILE_EXISTS - return True, message.format(path=path) - fallback = Messages.DIR_NOT_EXISTS if directory else Messages.FILE_NOT_EXISTS - return False, error_message or fallback.format(path=path) - - if not (target.exists() or target.is_symlink()): - message = Messages.DIR_NOT_EXISTS if directory else Messages.FILE_NOT_EXISTS - return True, message.format(path=path) - fallback = Messages.DIR_STILL_EXISTS if directory else Messages.FILE_STILL_EXISTS - return False, error_message or fallback.format(path=path) - - -class AnswerRule(Protocol): - @property - def required_message(self) -> str: ... - - def validate(self, answer: str) -> ValidationResult: ... - - -class Requirement(Protocol): - def check(self, state: GameStateProtocol, root: Path) -> ValidationResult: ... - - -@dataclass(frozen=True, slots=True) -class ExactAnswer: - expected: str - case_sensitive: bool = True - mistakes: Mapping[str, str] = field(default_factory=dict) - error_message: str | None = None - required_message: str = Messages.ANSWER_REQUIRED - - def validate(self, answer: str) -> ValidationResult: - actual = answer.strip() - compared_actual = actual if self.case_sensitive else actual.lower() - for mistake, message in self.mistakes.items(): - compared_mistake = mistake if self.case_sensitive else mistake.lower() - if compared_actual == compared_mistake: - return False, message - - compared_expected = self.expected if self.case_sensitive else self.expected.lower() - if compared_actual == compared_expected: - return True, Messages.CORRECT - return False, self.error_message or Messages.INCORRECT - - -@dataclass(frozen=True, slots=True) -class IntegerAnswer: - expected: int - mistakes: Mapping[int, str] = field(default_factory=dict) - error_message: str | None = None - invalid_message: str = Messages.EXPECTED_INTEGER - required_message: str = Messages.ANSWER_REQUIRED_NUMBER - - def validate(self, answer: str) -> ValidationResult: - try: - actual = int(answer.strip()) - except ValueError: - return False, self.invalid_message - if actual in self.mistakes: - return False, self.mistakes[actual] - if actual == self.expected: - return True, Messages.CORRECT - return False, self.error_message or Messages.INCORRECT - - -@dataclass(frozen=True, slots=True) -class IntegerRangeAnswer: - minimum: int - maximum: int - error_message: str | None = None - invalid_message: str = Messages.EXPECTED_INTEGER - required_message: str = Messages.ANSWER_REQUIRED_NUMBER - - def __post_init__(self) -> None: - if self.minimum > self.maximum: - raise ValueError("Integer answer minimum cannot exceed maximum") - - def validate(self, answer: str) -> ValidationResult: - try: - actual = int(answer.strip()) - except ValueError: - return False, self.invalid_message - if self.minimum <= actual <= self.maximum: - return True, Messages.CORRECT - return False, self.error_message or Messages.INCORRECT - - -@dataclass(frozen=True, slots=True) -class ChoiceAnswer: - accepted: tuple[str, ...] - case_sensitive: bool = True - error_message: str | None = None - required_message: str = Messages.ANSWER_REQUIRED - - def __post_init__(self) -> None: - if not self.accepted: - raise ValueError("Choice answer requires at least one accepted value") - - def validate(self, answer: str) -> ValidationResult: - actual = answer.strip() - if not self.case_sensitive: - actual = actual.lower() - accepted = {value.lower() for value in self.accepted} - else: - accepted = set(self.accepted) - if actual in accepted: - return True, Messages.CORRECT - return False, self.error_message or Messages.INCORRECT - - -@dataclass(frozen=True, slots=True) -class SuffixAnswer: - expected: str - case_sensitive: bool = True - error_message: str | None = None - required_message: str = Messages.ANSWER_REQUIRED - - def validate(self, answer: str) -> ValidationResult: - actual = answer.strip() - if not self.case_sensitive: - actual = actual.lower() - expected = self.expected.lower() - else: - expected = self.expected - if actual == expected or actual.endswith(f"/{expected}"): - return True, Messages.CORRECT - return False, self.error_message or Messages.INCORRECT - - -@dataclass(frozen=True, slots=True) -class TupleAnswer: - parts: tuple[AnswerRule, ...] - separator: str = "," - format_message: str = Messages.ANSWER_FORMAT - required_message: str = Messages.ANSWER_REQUIRED - - def validate(self, answer: str) -> ValidationResult: - values = [value.strip() for value in answer.split(self.separator)] - if len(values) != len(self.parts): - return False, self.format_message - for value, rule in zip(values, self.parts, strict=True): - success, message = rule.validate(value) - if not success: - return False, message - return True, Messages.CORRECT - - -@dataclass(frozen=True, slots=True) -class OrderedListAnswer: - expected: tuple[str, ...] - separator: str = "," - case_sensitive: bool = True - error_message: str | None = None - required_message: str = Messages.ANSWER_REQUIRED_LIST - - def validate(self, answer: str) -> ValidationResult: - actual = tuple(item.strip() for item in answer.split(self.separator) if item.strip()) - if self.case_sensitive: - matches = actual == self.expected - else: - matches = tuple(item.lower() for item in actual) == tuple(item.lower() for item in self.expected) - if matches: - return True, Messages.CORRECT - return False, self.error_message or Messages.INCORRECT - - -@dataclass(frozen=True, slots=True) -class AtDirectory: - path: str - error_message: str | None = None - - def __post_init__(self) -> None: - _relative_path(self.path) - - def check(self, state: GameStateProtocol, root: Path) -> ValidationResult: - expected = _resolve(root, self.path).resolve() - current = current_directory() - if current is None: - return False, Messages.CWD_MISSING - if current == expected: - return True, Messages.CORRECT - message = self.error_message or Messages.WRONG_DIRECTORY - return False, message - - -@dataclass(frozen=True, slots=True) -class AtHome: - def check(self, state: GameStateProtocol, root: Path) -> ValidationResult: - del root - current = current_directory() - if current is None: - return False, Messages.CWD_MISSING - expected = Path.home().resolve() - if current == expected: - return True, Messages.CORRECT - return False, Messages.NOT_AT_HOME.format(actual=current) - - -@dataclass(frozen=True, slots=True) -class Evidence: - marker: str - error_message: str = Messages.MARKER_NOT_FOUND - - def check(self, state: GameStateProtocol, root: Path) -> ValidationResult: - del root - if MarkerManager.from_state(state).exists(self.marker): - return True, Messages.CORRECT - return False, self.error_message - - -@dataclass(frozen=True, slots=True) -class FileExists: - path: str - should_exist: bool = True - error_message: str | None = None - - def __post_init__(self) -> None: - _relative_path(self.path) - - def check(self, state: GameStateProtocol, root: Path) -> ValidationResult: - del state - return _entry_exists( - root, - self.path, - directory=False, - should_exist=self.should_exist, - error_message=self.error_message, - ) - - -@dataclass(frozen=True, slots=True) -class DirectoryExists: - path: str - should_exist: bool = True - error_message: str | None = None - - def __post_init__(self) -> None: - _relative_path(self.path) - - def check(self, state: GameStateProtocol, root: Path) -> ValidationResult: - del state - return _entry_exists( - root, - self.path, - directory=True, - should_exist=self.should_exist, - error_message=self.error_message, - ) - - -@dataclass(frozen=True, slots=True) -class TextFileContent: - path: str - exact: str | None = None - contains: tuple[str, ...] = () - excludes: tuple[str, ...] = () - strip: bool = False - error_message: str = Messages.FILE_CONTENT_MISMATCH - missing_message: str | None = None - unreadable_message: str | None = None - - def __post_init__(self) -> None: - _relative_path(self.path) - - def check(self, state: GameStateProtocol, root: Path) -> ValidationResult: - target = _resolve(root, self.path) - if not target.is_file(): - message = self.missing_message or Messages.FILE_NOT_EXISTS.format(path=self.path) - return False, message - try: - content = target.read_text(encoding="utf-8") - except (OSError, UnicodeError) as error: - return False, self.unreadable_message or Messages.FILE_READ_ERROR.format(error=str(error)) - if self.strip: - content = content.strip() - if ( - (self.exact is not None and content != self.exact) - or any(value not in content for value in self.contains) - or any(value in content for value in self.excludes) - ): - return False, self.error_message - return True, Messages.FILE_CONTENT_CORRECT - - -@dataclass(frozen=True, slots=True) -class FileLineCount: - path: str - expected: int - error_message: str - missing_message: str | None = None - - def __post_init__(self) -> None: - _relative_path(self.path) - if self.expected < 0: - raise ValueError("File line count cannot be negative") - - def check(self, state: GameStateProtocol, root: Path) -> ValidationResult: - target = _resolve(root, self.path) - if not target.is_file(): - message = self.missing_message or Messages.FILE_NOT_EXISTS.format(path=self.path) - return False, message - try: - actual = len(target.read_text(encoding="utf-8").splitlines()) - except (OSError, UnicodeError) as error: - return False, Messages.FILE_READ_ERROR.format(error=str(error)) - if actual == self.expected: - return True, Messages.CORRECT - return False, self.error_message - - -@dataclass(frozen=True, slots=True) -class PathsMatch: - source: str - destination: str - error_message: str = Messages.COPY_CONTENT_MISMATCH - source_error: str | None = None - destination_error: str | None = None - - def __post_init__(self) -> None: - _relative_path(self.source) - _relative_path(self.destination) - - def check(self, state: GameStateProtocol, root: Path) -> ValidationResult: - source = _resolve(root, self.source) - destination = _resolve(root, self.destination) - if not source.exists(): - message = self.source_error or Messages.COPY_SOURCE_MISSING.format(path=self.source) - return False, message - if not destination.exists(): - message = self.destination_error or Messages.COPY_DEST_MISSING.format(path=self.destination) - return False, message - try: - matches = self._paths_match(source, destination) - except OSError as error: - return False, Messages.COMPARE_FAILED.format(error=error) - return (True, Messages.COPY_SUCCESS) if matches else (False, self.error_message) - - @classmethod - def _paths_match(cls, source: Path, destination: Path) -> bool: - if source.is_symlink() or destination.is_symlink(): - return source.is_symlink() and destination.is_symlink() and source.readlink() == destination.readlink() - if source.is_file() or destination.is_file(): - return source.is_file() and destination.is_file() and source.read_bytes() == destination.read_bytes() - if not source.is_dir() or not destination.is_dir(): - return False - - source_entries = {entry.relative_to(source) for entry in source.rglob("*")} - destination_entries = {entry.relative_to(destination) for entry in destination.rglob("*")} - if source_entries != destination_entries: - return False - return all(cls._paths_match(source / relative, destination / relative) for relative in source_entries) - - -@dataclass(frozen=True, slots=True) -class PathMoved: - source: str - destination: str - source_error: str | None = None - destination_error: str | None = None - - def __post_init__(self) -> None: - _relative_path(self.source) - _relative_path(self.destination) - - def check(self, state: GameStateProtocol, root: Path) -> ValidationResult: - if _resolve(root, self.source).exists(): - message = self.source_error or Messages.MOVE_SOURCE_EXISTS.format(path=self.source) - return False, message - if not _resolve(root, self.destination).exists(): - message = self.destination_error or Messages.MOVE_DEST_MISSING.format(path=self.destination) - return False, message - return True, Messages.MOVE_SUCCESS - - -@dataclass(frozen=True, slots=True) -class PermissionBits: - path: str - required: int = 0 - forbidden: int = 0 - error_message: str = Messages.PERMISSION_WRONG - - def __post_init__(self) -> None: - _relative_path(self.path) - if self.required & self.forbidden: - raise ValueError("Permission bits cannot be both required and forbidden") - - def check(self, state: GameStateProtocol, root: Path) -> ValidationResult: - target = _resolve(root, self.path) - if not target.is_file(): - return False, Messages.FILE_NOT_EXISTS.format(path=self.path) - mode = S_IMODE(target.stat().st_mode) - if mode & self.required == self.required and not mode & self.forbidden: - return True, Messages.PERMISSION_CORRECT - return False, self.error_message - - -@dataclass(frozen=True, slots=True) -class PermissionMode: - path: str - expected: int - error_message: str | None = None - - def __post_init__(self) -> None: - _relative_path(self.path) - if not 0 <= self.expected <= 0o7777: - raise ValueError("Permission mode must be between 0o0000 and 0o7777") - - def check(self, state: GameStateProtocol, root: Path) -> ValidationResult: - target = _resolve(root, self.path) - if not target.is_file(): - return False, Messages.FILE_NOT_EXISTS.format(path=self.path) - actual = S_IMODE(target.stat().st_mode) - if actual == self.expected: - return True, Messages.PERMISSION_CORRECT - return False, self.error_message or Messages.PERMISSION_WRONG - - -@dataclass(frozen=True, slots=True) -class DirectoryPermissionMode(PermissionMode): - @override - def check(self, state: GameStateProtocol, root: Path) -> ValidationResult: - del state - target = _resolve(root, self.path) - if not target.is_dir(): - return False, Messages.DIR_NOT_EXISTS.format(path=self.path) - actual = S_IMODE(target.stat().st_mode) - if actual == self.expected: - return True, Messages.PERMISSION_CORRECT - return False, self.error_message or Messages.PERMISSION_WRONG - - -@dataclass(frozen=True, slots=True) -class Completion: - answer: AnswerRule | None = None - requirements: tuple[Requirement, ...] = () - allow_empty: bool = False - allow_empty_when: Requirement | None = None - - @property - def evidence_markers(self) -> tuple[str, ...]: - markers = [requirement.marker for requirement in self.requirements if isinstance(requirement, Evidence)] - if isinstance(self.allow_empty_when, Evidence): - markers.append(self.allow_empty_when.marker) - return tuple(markers) - - def _check_requirements(self, state: GameStateProtocol, root: Path) -> ValidationResult | None: - for requirement in self.requirements: - try: - success, message = requirement.check(state, root) - except ContainedPathError: - return False, Messages.PATH_ESCAPES_WORKSPACE - if not success: - return False, message - return None - - def _validate_empty(self, state: GameStateProtocol, root: Path, success_message: str) -> ValidationResult: - if self.allow_empty: - return True, success_message - if self.allow_empty_when is not None: - try: - success, message = self.allow_empty_when.check(state, root) - except ContainedPathError: - return False, Messages.PATH_ESCAPES_WORKSPACE - return (True, success_message) if success else (False, message) - if self.answer is None: - return True, success_message - return False, self.answer.required_message - - def validate( - self, - answer: str | None, - state: GameStateProtocol, - *, - root: Path, - success_message: str, - ) -> ValidationResult: - if result := self._check_requirements(state, root): - return result - - if answer is None: - return self._validate_empty(state, root, success_message) - - if self.answer is not None: - success, message = self.answer.validate(answer) - if not success: - return False, message - - return True, success_message diff --git a/src/shellgame/levels/content/section0_intro.md b/src/shellgame/levels/content/section0_intro.md deleted file mode 100644 index e3b8135..0000000 --- a/src/shellgame/levels/content/section0_intro.md +++ /dev/null @@ -1,20 +0,0 @@ -Vítejte v interaktivní výuce příkazové řádky! - -### O hře - -ShellGame je série úkolů, které plníte přímo ve svém terminálu. -Hra vám zadá úkol, vy ho splníte pomocí příkazů shellu a poté odevzdáte výsledek. Hra sleduje, jaké příkazy jste použili a jaké výsledky jste získali. -Výsledkem může být textová odpověď, přesun do správného adresáře nebo úprava souborů a adresářů. - -### Jak hrát -1. Hra se skládá ze sekcí, které se věnují různým tématům. -2. Každá sekce začíná úvodním textem. Úvod jen čtete — pokračujte stisknutím Enter. -3. Každá sekce obsahuje několik levelů, ve kterých postupně plníte úkoly. -4. **Odevzdejte řešení úkolu** příkazem `shellgame submit`. -5. Pokud si nevíte rady, můžete si vyžádat nápovědu příkazem: -`shellgame hint`. - -### Další příkazy - -Další příkazy (např. restartování hry) naleznete v nápovědě: -`shellgame --help` diff --git a/src/shellgame/levels/content/section10_intro.md b/src/shellgame/levels/content/section10_intro.md deleted file mode 100644 index 0458982..0000000 --- a/src/shellgame/levels/content/section10_intro.md +++ /dev/null @@ -1,37 +0,0 @@ -# Sekce 10: Chybové výstupy - -Každý příkaz v Linuxu má dva výstupní kanály: -1. **stdout** (fd 1) - standardní výstup pro normální výsledky -2. **stderr** (fd 2) - chybový výstup pro chybové zprávy - -## Dva proudy výstupu -``` -┌─────────┐ -│ příkaz │──── stdout (1) ───▶ Normální výstup -│ │──── stderr (2) ───▶ Chybové zprávy -└─────────┘ -``` - -## Operátory přesměrování -``` -> Přesměruje stdout (normální výstup) -2> Přesměruje stderr (chyby) -&> Přesměruje OBOJÍ (stdout + stderr) ->> Přidá stdout na konec souboru -2>> Přidá stderr na konec souboru -``` - -## Příklad -```bash -./skript.sh # Obojí na obrazovku -./skript.sh > out.log # stdout do souboru, stderr na obrazovku -./skript.sh 2> err.log # stderr do souboru, stdout na obrazovku -./skript.sh &> all.log # Všechno do souboru -./skript.sh &> /dev/null # Zahodí všechno (ticho) -``` - -## Co se naučíte: -- Standardní chybový výstup (stderr) -- Přesměrování chybových zpráv (`2>`) -- Přesměrování všeho (`&>`) -- Zahazování výstupu (`/dev/null`) diff --git a/src/shellgame/levels/content/section11_intro.md b/src/shellgame/levels/content/section11_intro.md deleted file mode 100644 index 7862f56..0000000 --- a/src/shellgame/levels/content/section11_intro.md +++ /dev/null @@ -1,40 +0,0 @@ -# Sekce 11: Vyhledávání - -V této závěrečné sekci se naučíte, jak najít text uvnitř souborů a jak najít samotné soubory. - -## Dva typy hledání -``` -grep = Hledání UVNITŘ souborů (obsah) -find = Hledání SOUBORŮ samotných (podle názvu, typu, velikosti...) -``` - -## grep - hledání textu -``` -grep "error" log.txt Najde řádky s "error" -grep -r "TODO" projekt/ Rekurzivně v celém adresáři -grep -i "warning" *.log Ignoruje velikost písmen -``` - -## find - hledání souborů -``` -find . -type f -name "*.py" Všechny běžné .py soubory -find /home -type f -name "config*" Soubory začínající na "config" -find . -type d -name "test*" Pouze adresáře -``` - -Uvozovky kolem `"*.py"` jsou důležité: zabrání shellu, aby hvězdičku -rozbalil předem. Doslovný vzor tak dostane příkaz `find`, který ho vyhodnotí -v každém prohledávaném adresáři. - -## Proč je to důležité? -``` -Ztratili jste soubor? → find . -type f -name "soubor.txt" -Hledáte kde je chyba v kódu? → grep -r "ERROR" src/ -Kolik TODOs máte v projektu? → grep -r "TODO" . | wc -l -``` - -## Co se naučíte: -- Hledat text v souborech (`grep`) -- Rekurzivní hledání (`grep -r`) -- Hledat soubory podle typu a názvu (`find -type f -name`) -- Kombinovat nástroje diff --git a/src/shellgame/levels/content/section1_intro.md b/src/shellgame/levels/content/section1_intro.md deleted file mode 100644 index 6b96193..0000000 --- a/src/shellgame/levels/content/section1_intro.md +++ /dev/null @@ -1,52 +0,0 @@ -Vítejte v první sekci ShellGame! Zde se naučíte základní dovednosti pro pohyb v příkazové řádce. Prvním krokem k ovládnutí příkazové řádky je pochopení, jak funguje souborový systém vašeho počítače. - -### Stromová struktura - -Souborový systém vašeho počítače je organizován jako stromová struktura adresářů (složek) a souborů. Strom začíná v kořenovém adresáři (root, označeném `/`). Každý adresář může obsahovat další podadresáře, což vytváří hierarchii. - -``` -/ ← Kořen (root) - začátek všeho -├── home/ ← Domovské adresáře uživatelů -│ └── student/ ← VÁŠ domovský adresář (~) -│ ├── dokumenty/ -│ ├── stažené/ -│ └── projekty/ -├── tmp/ ← Dočasné soubory -│ └── shellgame-<user>/ ← Váš pracovní prostor pro hru (např. shellgame-student/) -│ └── level-1/ ← ZDE ZAČÍNÁTE! -├── etc/ ← Systémové konfigurace -└── usr/ ← Programy a knihovny -``` - -### Working Directory - -V každé chvíli se v terminálu nacházíte v určitém adresáři, který se nazývá "pracovní adresář" (working directory). Je to stejné, jako když máte v grafickém rozhraní otevřenou složku a vidíte její obsah. V terminálu však musíte vědět, kde jste, abyste mohli správně zadávat příkazy. - -V této sekci si představujte, obrázek souborového stromu jako výše a na něm si představujte šipku stále ukazující na váš aktuální pracovní adresář. Pokaždé, když se někam přesunete pomocí příkazu `cd`, šipka vás bude následovat. - -### Cesta (Path) - -Cesta (path) je textový řetězec popisující umístění souboru nebo adresáře v systému. Cesta se skládá z jednotlivých adresářů oddělených lomítky (např. `/home/student/dokumenty`). - ---- - -### Klíčové koncepty - -V této sekci se zaměříme na tři klíčové dovednosti: -1. **Orientace**: Jak zjistit, kde se právě nacházíte. V grafickém rozhraní to vidíte v záhlaví okna, v terminálu se musíte zeptat. -2. **Průzkum**: Jak zjistit, co se nachází ve vašem okolí. Naučíte se vypisovat obsah adresářů a rozpoznávat různé typy položek. -3. **Pohyb**: Jak se přesouvat mezi adresáři. Naučíte se chodit "dovnitř" do podadresářů, vracet se "zpět" a skákat na konkrétní místa. - -### Příkazy, které si osvojíte - -- `pwd` (Print Working Directory) - „Kde jsem?“ -- `ls` (List) - „Co tu je?“ -- `<příkaz> --help` - stručná nápověda k příkazu -- `man <příkaz>` - podrobný manuál -- `cd` (Change Directory) - „Jdi tam!“ - -### Dva triky pro pohodlné psaní -- **Klávesa Tab (doplňování)**: Napište začátek jména a stiskněte Tab. Shell zbytek doplní sám, což vás uchrání od překlepů. -- **Šipky nahoru/dolů (historie)**: Stiskem šipky ↑ vyvoláte předchozí příkaz, aniž byste ho museli psát znovu. - -Jste připraveni začít svou cestu? diff --git a/src/shellgame/levels/content/section2_intro.md b/src/shellgame/levels/content/section2_intro.md deleted file mode 100644 index 4644cec..0000000 --- a/src/shellgame/levels/content/section2_intro.md +++ /dev/null @@ -1,38 +0,0 @@ -### Sekce 2: Práce se soubory - -V této sekci se naučíte pracovat se soubory a pokročilejší navigaci. - -### Proč je to důležité? -Skutečná práce v terminálu je stálé přebíhání mezi několika místy: -zdrojový kód a testy, konfigurace a log. Kdo umí jen `cd jmeno`, -vypisuje pokaždé celou cestu znovu. Po této sekci se mezi dvěma místy -přepnete jedním krátkým příkazem — a obsah souboru si zobrazíte, -aniž byste otevírali editor. - -### Navigace mezi sourozeneckými adresáři -``` -level-2/ -├── start/ ← Jste tady -├── finish/ ← Chcete se sem dostat -└── other/ - -Jak se dostat ze 'start' do 'finish'? -1. Krok zpět: cd .. (jste v level-2) -2. Krok dovnitř: cd finish (jste ve finish) - -Nebo jedním příkazem: cd ../finish -``` - -### Co se naučíte -- Navigace mezi sousedními adresáři (`cd ../jiný`) -- Rychlý návrat do předchozího adresáře (`cd -`) -- Čtení obsahu souborů (`cat`) -- Přerušení běžícího příkazu klávesovou zkratkou (**Ctrl+C**) -- Vytváření souborů (`touch`) - -### Proč je `cd -` užitečný? -Když často přepínáte mezi dvěma místy (např. zdrojový kód a testy), -`cd -` vás vrátí tam, kde jste byli naposledy - bez psaní celé cesty. - -### Pokračování -Pro zahájení prvního levelu této sekce stiskněte Enter. diff --git a/src/shellgame/levels/content/section3_intro.md b/src/shellgame/levels/content/section3_intro.md deleted file mode 100644 index 090ae6a..0000000 --- a/src/shellgame/levels/content/section3_intro.md +++ /dev/null @@ -1,38 +0,0 @@ -### Sekce 3: Skryté soubory - -V této sekci odhalíte tajemství skrytých souborů a adresářů. - -### Proč je to důležité? -„Smazal jsem si nastavení editoru a teď ho nemůžu najít.“ -„Projekt má fungovat, ale chybí mu `.env`.“ -„Git se chová divně — kde vůbec ta konfigurace je?“ - -Všechny tři odpovědi leží v souborech, které `ls` záměrně neukáže. -Dokud o nich nevíte, vypadá adresář prázdně — i když prázdný není. - -### Proč existují skryté soubory? -V Linuxu soubory začínající tečkou (`.`) jsou "skryté" - nezobrazí se při běžném `ls`. -Používají se pro konfigurační soubory, které nechcete mít na očích při běžné práci. - -### Běžné skryté soubory -``` -~/ ← Váš domovský adresář -├── dokumenty/ ← Běžný adresář (viditelný) -├── .bashrc ← Konfigurace shellu (SKRYTÝ) -├── .config/ ← Konfigurační adresář (SKRYTÝ) -│ └── shellgame/ -│ └── state.json -└── .ssh/ ← SSH klíče (SKRYTÝ) - -Příkaz 'ls' zobrazí pouze: dokumenty/ -Příkaz 'ls -a' zobrazí VŠE včetně skrytých! -Příkaz 'ls -la' přidá podrobnosti; adresář poznáte podle 'd' na začátku řádku. -``` - -### Co se naučíte -- Co jsou skryté soubory (začínají tečkou) -- Jak je zobrazit a rozlišit jejich typ (`ls -a`, `ls -la`) -- Jak s nimi pracovat (čtení, navigace) - -### Pokračování -Pro zahájení prvního levelu této sekce stiskněte Enter. diff --git a/src/shellgame/levels/content/section4_intro.md b/src/shellgame/levels/content/section4_intro.md deleted file mode 100644 index 05a3f1f..0000000 --- a/src/shellgame/levels/content/section4_intro.md +++ /dev/null @@ -1,44 +0,0 @@ -### Sekce 4: Vytváření a mazání - -V této sekci se naučíte, jak vytvářet nové soubory a adresáře a jak po sobě uklidit. - -### Proč je to důležité? -Zatím jste se pohybovali po struktuře, kterou někdo připravil za vás. -Každý nový projekt ale začíná prázdným adresářem, který si musíte založit sami — -a na serveru, kde není žádné grafické rozhraní, je příkazová řádka jediná možnost. -S úklidem přichází i odpovědnost: mazání v terminálu je okamžité a konečné. - -### Životní cyklus položek -Každá položka v systému prochází třemi fázemi: -- **Vytvoření**: `touch soubor.txt` / `mkdir projekt/` -- **Práce**: úprava obsahu, čtení, navigace -- **Úklid**: `rm soubor.txt` / `rm -r projekt/` - -### Vytváření zanořených struktur (`mkdir -p`) -Příkaz `mkdir` běžně vyžaduje, aby rodičovský adresář už existoval: - -``` -$ mkdir a/b/c -mkdir: cannot create directory 'a/b/c': No such file or directory -``` -❌ **Chyba**: nadřazené složky `a` ani `b` ještě neexistují. - -S přepínačem **`-p`** (parents) shell vytvoří celou cestu najednou: - -``` -$ mkdir -p a/b/c -``` -✓ **Výsledek**: automaticky se vytvoří celá větev `a/` → `a/b/` → `a/b/c/`. - -### Co se naučíte -- Vytvářet prázdné soubory (`touch`) -- Vytvářet adresáře (`mkdir`) -- Vytvářet zanořené struktury (`mkdir -p`) -- Mazat soubory (`rm`) -- Mazat adresáře (`rmdir`, `rm -r`) - -### ⚠️ Pozor na `rm`! -Příkaz `rm` nepřesouvá soubory do koše — maže je rovnou a nevratně. - -### Pokračování -Pro zahájení prvního levelu této sekce stiskněte Enter. diff --git a/src/shellgame/levels/content/section5_intro.md b/src/shellgame/levels/content/section5_intro.md deleted file mode 100644 index 6cbaff3..0000000 --- a/src/shellgame/levels/content/section5_intro.md +++ /dev/null @@ -1,53 +0,0 @@ -### Sekce 5: Zkoumání souborů - -V této sekci se naučíte, jak zjistit více informací o souborech, než jen jejich název. - -### Proč je to důležité? - -#### Bezpečnost -Stáhli jste soubor `faktura.pdf` z e-mailu. Ale je to opravdu PDF? -Příkaz `file` pomůže odhalit skutečný formát a podezřelý nesoulad s názvem. -Nejde však o antivirovou ani úplnou bezpečnostní kontrolu. - -#### Praktická práce -- **Vývojář**: "Proč mi nefunguje import? Ten soubor vypadá prázdný..." → `ls -l` ukáže, že má 0 bajtů -- **Admin**: "Který log zabírá místo na disku?" → `ls -lh` ukáže velikosti čitelně -- **Admin**: "Kde je chyba v dlouhém logu?" → `less` umožní soubor procházet a prohledávat -- **Student**: "Je tohle textový soubor nebo binární?" → `file` řekne přesný typ - -### Výstup `ls -l` vysvětlen -``` --rw-r--r-- 1 student users 12345 Dec 5 10:30 dokument.txt -│├──┼──┼──│ │ │ │ │ │ -││ │ │ │ │ │ │ │ └─ název -││ │ │ │ │ │ │ └─ datum změny -││ │ │ │ │ │ └─ velikost v bajtech -││ │ │ │ │ └─ skupina -││ │ │ │ └─ vlastník -││ │ │ └─ oprávnění pro ostatní (r--) -││ │ └─ oprávnění pro skupinu (r--) -││ └─ oprávnění pro vlastníka (rw-) -│└─ typ souboru (- = soubor, d = adresář) -``` - ---- - -### Příkaz `file` — pravda o obsahu -Názvy souborů mohou být klamavé; příkaz `file` zkoumá skutečný obsah: - -``` -$ file foto.jpg -foto.jpg: JPEG image data ← Opravdu obrázek - -$ file fake.jpg -fake.jpg: ASCII text ← Někdo lhal! Je to text. -``` - -### Co se naučíte -- Zjišťovat velikost souborů (`ls -l`, `ls -lh`) -- Procházet a prohledávat velké soubory (`less`, `/`, `q`) -- Určovat typ souboru (`file`) -- Rozlišovat mezi textovými a binárními soubory - -### Pokračování -Pro zahájení prvního levelu této sekce stiskněte Enter. diff --git a/src/shellgame/levels/content/section6_intro.md b/src/shellgame/levels/content/section6_intro.md deleted file mode 100644 index 7d88be2..0000000 --- a/src/shellgame/levels/content/section6_intro.md +++ /dev/null @@ -1,44 +0,0 @@ -# Sekce 6: Kopírování a přesouvání - -V této sekci se naučíte manipulovat se soubory a adresáři - vytvářet jejich kopie a přesouvat je na jiná místa. - -## Proč je to důležité? -„Před úpravou konfigurace si udělej zálohu.“ — rada, kterou uslysíte všude, -a která se v terminálu vejde do jednoho příkazu. - -`cp` a `mv` jsou zároveň první příkazy, které doopravdy přepisují vaše data. -Oba umí bez varování přepsat existující cíl — proto se už teď vyplatí -zvyknout si nejdřív zkontrolovat, co na cílovém místě leží. - -## `cp` vs `mv` - jaký je rozdíl? -``` -cp (copy) mv (move) -───────────────────────────────────────────── -Původní soubor ZŮSTANE Původní soubor ZMIZÍ -Vytvoří duplikát Přesune/přejmenuje - -cp a.txt b.txt mv a.txt b.txt -├── a.txt ← zůstává └── b.txt ← jediný soubor -└── b.txt ← nový -``` - -## Pozor na existující cíl -`cp` i `mv` mohou bez dalšího dotazu přepsat existující cílový soubor. -Předem zkontrolujte cíl pomocí `ls`, případně použijte `cp -i` nebo `mv -i`. - -## Přejmenování = přesun na stejném místě -``` -mv stary_nazev.txt novy_nazev.txt - │ │ - └──────────────────┘ - "přesun" = přejmenování -``` - -## Co se naučíte: -- Kopírovat soubory (`cp`) -- Kopírovat adresáře (`cp -r`) -- Přejmenovávat soubory a adresáře (`mv`) -- Přesouvat soubory a adresáře (`mv`) - -### Tip: `-r` znamená "rekurzivně" -Pro kopírování/mazání adresářů potřebujete `-r`, aby se zpracoval i jejich obsah. diff --git a/src/shellgame/levels/content/section7_intro.md b/src/shellgame/levels/content/section7_intro.md deleted file mode 100644 index 481f79f..0000000 --- a/src/shellgame/levels/content/section7_intro.md +++ /dev/null @@ -1,52 +0,0 @@ -# Sekce 7: Žolíky (Wildcards) - -V této sekci se naučíte pracovat s více soubory najednou pomocí žolíků (wildcards). -Žolíky vám umožní definovat vzory názvů souborů. - -## Kdy potřebujete Bash? -Hvězdička `*` funguje v Bashi i ve fish. Levely **7.2–7.4 vyžadují Bash**: -otazník `?`, množiny `[...]` a znakové třídy nejsou přenositelné do fish. -Stejnou bashovou syntaxi si znovu procvičíte v souhrnu 7.5. - -Pokud hrajete ve fish, každý z těchto levelů nabídne příkaz ve tvaru -`bash -c 'příkaz'`. Ten spustí pouze daný příkaz v Bashi a vrátí vás do hry. -Vnější jednoduché uvozovky zachovají žolíky pro Bash. -`shellgame submit` pak zadejte jako obvykle ve svém herním shellu. - -## Přehled žolíků v Bashi -``` -* Jakýkoliv počet znaků (včetně nuly) -? Právě jeden znak -[...] Jeden ze znaků v závorkách -[a-z] Rozsah znaků (závisí na locale) -[[:lower:]] Jeden znak klasifikovaný jako malé písmeno -``` - -## Příklady pro Bash -``` -*.txt → všechny .txt soubory -data?.csv → data1.csv, data2.csv, ale NE data10.csv -file_[ab].md → file_a.md, file_b.md, ale NE file_c.md -[[:lower:]]*.py → soubory začínající malým písmenem -``` - -> ⚠️ **Pozor na rozsahy:** `[a-z]` a `[A-Z]` vycházejí z pořadí znaků -> nastaveného locale. Pro význam „mailé písmeno“ proto v levelu 7.4 použijete -> POSIX třídu `[[:lower:]]`; podobně existují `[[:upper:]]` a `[[:digit:]]`. - -## Jak to funguje? -``` -Vy napíšete: Shell expanduje na: -cp *.jpg imgs/ cp foto1.jpg foto2.jpg foto3.jpg imgs/ - │ │ - └── žolík ──────────┘ skutečné soubory -``` - -## Co se naučíte: -- Vybírat soubory hvězdičkou (`*`) -- Přesně jeden znak otazníkem (`?`) -- Množinu znaků hranatými závorkami (`[abc]`) -- Rozsahy a locale-odolné třídy znaků (`[a-z]`, `[[:lower:]]`, `[[:digit:]]`) - -## Pokračování -Pro zahájení prvního levelu této sekce stiskněte Enter. diff --git a/src/shellgame/levels/content/section8_intro.md b/src/shellgame/levels/content/section8_intro.md deleted file mode 100644 index f1ad8e8..0000000 --- a/src/shellgame/levels/content/section8_intro.md +++ /dev/null @@ -1,67 +0,0 @@ -# Sekce 8: Oprávnění - -V Linuxu má každý soubor a adresář nastavená oprávnění, která určují, kdo s ním může co dělat. - -## Proč je to důležité? - -### Bezpečnost systému -- **Hesla**: Soubor `/etc/shadow` obsahuje hesla - smí ho číst jen root! -- **Konfigurace**: Webový server nesmí měnit vlastní konfiguraci (jen číst) -- **Sdílení**: Spolužáci nevidí vaše soukromé soubory v domovském adresáři - -### Běžné situace -```bash -# "Permission denied" při spuštění skriptu? -$ ./muj_skript.sh -bash: ./muj_skript.sh: Permission denied -$ chmod u+x muj_skript.sh # Přidá spuštění pro vlastníka -$ ./muj_skript.sh -Hello World! - -# Webový server nevidí soubory? -$ chmod 644 index.html # Ostatní mohou číst -``` - -## Tři typy oprávnění -``` -r (read) → Číst obsah souboru / vypsat obsah adresáře -w (write) → Měnit obsah souboru / vytvářet a mazat položky v adresáři -x (execute) → Spustit jako program / vstoupit do adresáře -``` - -Smazání souboru závisí na právech `w` a `x` nadřazeného adresáře, -ne na právu `w` samotného souboru. - ---- - -## Tři skupiny uživatelů -``` -u (user) → Vlastník souboru (vy) -g (group) → Členové skupiny vlastníka -o (other) → Všichni ostatní -``` - -## Jak číst `ls -l` -``` --rwxr-xr-- = vlastník: rwx, skupina: r-x, ostatní: r-- - ││││││││ - │├┴┤├┴┤├┴┤ - │ u g o - │ - └─ typ (- soubor, d adresář) -``` - -## Dva způsoby zápisu chmod -``` -Symbolický: Číselný (oktalový): -chmod u+x soubor chmod 755 soubor -chmod g-w soubor -chmod o=r soubor r=4, w=2, x=1 - 755 = rwx|r-x|r-x -``` - -## Co se naučíte: -- Číst oprávnění (`ls -l`) -- Měnit oprávnění (`chmod`) -- Používat symbolický zápis (`u+x`) i číselný zápis (`755`) -- Předvídat a prakticky ověřit, jak `w` a `x` ovlivňují práci s adresáři diff --git a/src/shellgame/levels/content/section9_intro.md b/src/shellgame/levels/content/section9_intro.md deleted file mode 100644 index bbec513..0000000 --- a/src/shellgame/levels/content/section9_intro.md +++ /dev/null @@ -1,52 +0,0 @@ -# Sekce 9: Vstup, výstup a stav příkazů - -Příkaz obvykle posílá výstup na obrazovku. Tři operátory rozhodnou, kam poteče dál: - -```text -příkaz > soubor výstup přepíše soubor -příkaz >> soubor výstup se přidá na konec -příkaz | další výstup se stane vstupem dalšího příkazu -``` - -## Tři mentální modely - -- `>` je **nový zápis**. Starý obsah cílového souboru zmizí. -- `>>` je **připojení**. Dosavadní obsah zůstane. -- `|` je **roura mezi příkazy**. Nevytváří soubor, jen předává data dál. - -```bash -ls > seznam.txt -echo "další řádek" >> seznam.txt -grep ERROR access.log | wc -l -``` - -Má-li text nebo název souboru mezery, uzavřete každou takovou část zvlášť: - -```bash -echo "Ahoj svete" > "muj pozdrav.txt" -``` - ---- - -# Klávesnicový vstup a stav příkazu - -`cat > soubor` čte řádky z klávesnice. **Ctrl+D** oznámí EOF, tedy konec vstupu, -a `cat` řádně skončí. **Ctrl+C** je interrupt: běžící příkaz přeruší. - -Každý příkaz také vrací stavový neboli návratový kód: - -- `0` znamená úspěch, -- nenulový kód znamená neúspěch, -- `první && druhý` pokračuje jen po úspěchu, -- `první || druhý` pokračuje jen po neúspěchu. - -`&&` a `||` můžete takto používat přímo v podporovaném Bash i Fish. - -## Co se naučíte - -- ukládat a přidávat výstup pomocí `>` a `>>` -- správně citovat víceslovný text i názvy souborů -- zapisovat interaktivní vstup přes `cat` a ukončit jej pomocí EOF -- propojovat příkazy pomocí `|` -- filtrovat a zpracovávat text přes `grep`, `head`, `tail`, `wc`, `sort` a `uniq` -- reagovat na úspěch či neúspěch příkazu pomocí `&&` a `||` diff --git a/src/shellgame/levels/fixture.py b/src/shellgame/levels/fixture.py deleted file mode 100644 index 0d6dbfd..0000000 --- a/src/shellgame/levels/fixture.py +++ /dev/null @@ -1,173 +0,0 @@ -"""Declarative workspace fixtures for level setup.""" - -from __future__ import annotations - -import contextlib -import os -import shutil -import stat -from dataclasses import dataclass -from pathlib import Path - -from shellgame.paths import relative_path, resolve_within - -_KIND = "Fixture" - - -def _safe_relative_path(value: str) -> Path: - return relative_path(value, kind=_KIND) - - -def _fixture_path(root: Path, value: str, *, allow_leaf_symlink: bool = False) -> Path: - return resolve_within(root, value, kind=_KIND, allow_leaf_symlink=allow_leaf_symlink) - - -def _safe_rmtree(path: Path) -> None: - """Recursively remove a directory tree, recovering from restricted permissions.""" - if path.is_symlink(): - path.unlink(missing_ok=True) - return - if not path.exists(): - return - with contextlib.suppress(OSError): - path.chmod(stat.S_IRWXU) - for root, dirs, files in os.walk(path, followlinks=False): - for name in dirs: - item = Path(root) / name - if not item.is_symlink(): - with contextlib.suppress(OSError): - item.chmod(stat.S_IRWXU) - for name in files: - item = Path(root) / name - if not item.is_symlink(): - with contextlib.suppress(OSError): - item.chmod(stat.S_IRWXU) - shutil.rmtree(path) - - -def _ensure_directory(root: Path, relative: str, *, repair_symlinks: bool = False) -> None: - path = _safe_relative_path(relative) - if root.exists() and root.is_dir(): - with contextlib.suppress(OSError): - root.chmod(stat.S_IRWXU) - _fixture_path(root, "" if repair_symlinks else relative) - root.mkdir(parents=True, exist_ok=True) - current = Path() - for part in path.parts: - current /= part - directory = _fixture_path(root, str(current), allow_leaf_symlink=repair_symlinks) - if directory.exists() or directory.is_symlink(): - if directory.is_symlink(): - directory.unlink(missing_ok=True) - elif not directory.is_dir(): - with contextlib.suppress(OSError): - directory.chmod(stat.S_IRUSR | stat.S_IWUSR) - directory.unlink() - else: - with contextlib.suppress(OSError): - directory.chmod(stat.S_IRWXU) - directory.mkdir(exist_ok=True) - - -@dataclass(frozen=True, slots=True) -class DirectoryFixture: - path: str - mode: int - - def __post_init__(self) -> None: - if not _safe_relative_path(self.path).parts: - raise ValueError("Directory fixture cannot target its root") - if not 0 <= self.mode <= 0o7777: - raise ValueError("Directory fixture mode must be between 0o0000 and 0o7777") - - def apply(self, root: Path) -> None: - _ensure_directory(root, self.path, repair_symlinks=True) - _fixture_path(root, self.path).chmod(self.mode) - - -@dataclass(frozen=True, slots=True) -class FileFixture: - path: str - content: str | bytes = "" - mode: int | None = None - overwrite: bool = True - - def __post_init__(self) -> None: - if not _safe_relative_path(self.path).parts: - raise ValueError("File fixture cannot target its root") - - def apply(self, root: Path) -> None: - destination = _fixture_path(root, self.path) - _ensure_directory(root, str(Path(self.path).parent)) - if self.overwrite or not destination.is_file(): - self._replace(destination) - if self.mode is not None: - destination.chmod(self.mode) - - def _replace(self, destination: Path) -> None: - """Restore the file after permission or file/directory mistakes. - - Section 7 teaches `chmod`, so a player can legitimately leave a fixture - file read-only. Reset has to hand back a clean level regardless of what - the previous attempt did to it, and a fixture that cannot overwrite its - own file would fail the one command meant to recover from that. - """ - if destination.is_symlink(): - destination.unlink(missing_ok=True) - elif destination.is_dir(): - _safe_rmtree(destination) - elif destination.exists(): - with contextlib.suppress(OSError): - destination.chmod(stat.S_IRUSR | stat.S_IWUSR) - destination.unlink() - if isinstance(self.content, bytes): - destination.write_bytes(self.content) - else: - destination.write_text(self.content, encoding="utf-8") - - -@dataclass(frozen=True, slots=True) -class WorkspaceFixture: - directories: tuple[str, ...] = () - files: tuple[FileFixture, ...] = () - clean: tuple[str, ...] = () - directory_fixtures: tuple[DirectoryFixture, ...] = () - - def __post_init__(self) -> None: - for relative in self.directories: - _safe_relative_path(relative) - for relative in self.clean: - path = _safe_relative_path(relative) - if not path.parts: - raise ValueError("Fixture cleanup cannot target its root") - - def apply(self, root: Path) -> None: - _ensure_directory(root, "") - - for relative in self.clean: - target = _fixture_path(root, relative, allow_leaf_symlink=True) - if target.is_symlink(): - target.unlink(missing_ok=True) - elif target.is_file(): - with contextlib.suppress(OSError): - target.chmod(stat.S_IRUSR | stat.S_IWUSR) - target.unlink(missing_ok=True) - elif target.is_dir(): - _safe_rmtree(target) - - for relative in self.directories: - _ensure_directory(root, relative) - - for directory_fixture in self.directory_fixtures: - _ensure_directory(root, directory_fixture.path, repair_symlinks=True) - - for file_fixture in self.files: - file_fixture.apply(root) - - mode_fixtures = sorted( - self.directory_fixtures, - key=lambda fixture: len(_safe_relative_path(fixture.path).parts), - reverse=True, - ) - for directory_fixture in mode_fixtures: - directory_fixture.apply(root) diff --git a/src/shellgame/levels/loader.py b/src/shellgame/levels/loader.py deleted file mode 100644 index f0d607a..0000000 --- a/src/shellgame/levels/loader.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Initialize and register all game levels.""" - -from __future__ import annotations - -import importlib -import pkgutil -import re -from types import ModuleType - -from shellgame.levels import sections -from shellgame.levels.base import Level -from shellgame.levels.collector import Section -from shellgame.levels.registry import get_registry - - -def _get_section_number(module_name: str) -> int | None: - match = re.search(r"section(\d+)$", module_name) - if match: - return int(match.group(1)) - return None - - -def _discover_sections() -> list[tuple[int, ModuleType]]: - found_sections = [] - - for _, name, _ in pkgutil.iter_modules(sections.__path__): - section_num = _get_section_number(name) - if section_num is not None: - module_name = f"shellgame.levels.sections.{name}" - module = importlib.import_module(module_name) - found_sections.append((section_num, module)) - - found_sections.sort(key=lambda x: x[0]) - return found_sections - - -def initialize_levels() -> None: - registry = get_registry() - if registry.list_levels(): - return - - for section_num, module in _discover_sections(): - registry.register_section(section_num, _section_levels(module)) - - -def _section_levels(module: ModuleType) -> list[Level]: - """Read the levels a section module declared via its `Section` object. - - Every section already builds one through `@section.level(...)`, so requiring - an extra `get_levels()` at the bottom of each module was boilerplate that - could silently go missing or return the wrong list. - """ - section = getattr(module, "section", None) - if isinstance(section, Section): - return section.levels - - raise ValueError(f"Section module declares no `section` object: {module.__name__}") diff --git a/src/shellgame/levels/registry.py b/src/shellgame/levels/registry.py deleted file mode 100644 index 873108e..0000000 --- a/src/shellgame/levels/registry.py +++ /dev/null @@ -1,102 +0,0 @@ -"""Level registry for managing all game levels.""" - -from shellgame.levels.base import Level - - -def parse_level_id(level_id: str) -> tuple[int, int] | None: - """Split a persistent level ID into `(section, number)`, or None if malformed.""" - section, separator, number = level_id.partition(".") - if not separator or not section.isdigit() or not number.isdigit(): - return None - return (int(section), int(number)) - - -class UnknownLevelError(LookupError): - """Raised when a level ID is not registered at all.""" - - -class LevelRegistry: - def __init__(self) -> None: - self.levels: dict[str, Level] = {} - self._level_order: list[str] = [] - - def register(self, level: Level) -> None: - if not level.id: - raise ValueError("Cannot register level without id") - if level.id in self.levels and self.levels[level.id] is not level: - raise ValueError(f"Duplicate level ID: {level.id}") - - self.levels[level.id] = level - if level.id not in self._level_order: - self._level_order.append(level.id) - - def register_section(self, section_num: int, levels: list[Level]) -> None: - registered: list[Level] = [] - for level in levels: - level_id = level.id - id_section, _, id_number = level_id.partition(".") - if ( - level.section != section_num - or not id_section.isdigit() - or int(id_section) != section_num - or not id_number.isdigit() - ): - raise ValueError(f"Invalid stable ID {level_id!r} for section {section_num}") - registered.append(level) - - for level in sorted(registered, key=lambda item: parse_level_id(item.id) or (0, 0)): - self.register(level) - - def get(self, level_id: str) -> Level | None: - return self.levels.get(level_id) - - def next_level(self, current_id: str) -> str | None: - """The level after `current_id`, or None when it is the last one. - - An unregistered ID is a different situation entirely and raises, so that - a stale saved ID can never be mistaken for "the player finished". - """ - try: - current_index = self._level_order.index(current_id) - except ValueError as error: - raise UnknownLevelError(current_id) from error - - if current_index + 1 < len(self._level_order): - return self._level_order[current_index + 1] - return None - - def resolve_or_nearest(self, level_id: str) -> str | None: - """Map a possibly stale saved ID onto a level that still exists. - - Levels get renumbered between versions, which would otherwise leave a - saved game permanently unplayable. Resolution keeps the player as close - as possible to where they were: the same section if it still exists, - otherwise the next section that does. - """ - if level_id in self.levels: - return level_id - if not self._level_order: - return None - - target = parse_level_id(level_id) - if target is None: - return self._level_order[0] - - candidates = [(key, known) for known in self._level_order if (key := parse_level_id(known)) is not None] - if not candidates: - return self._level_order[0] - - later = [known for key, known in candidates if key >= target] - if later: - return later[0] - return candidates[-1][1] - - def list_levels(self) -> list[Level]: - return [self.levels[lid] for lid in self._level_order] - - -_registry = LevelRegistry() - - -def get_registry() -> LevelRegistry: - return _registry diff --git a/src/shellgame/levels/sections/__init__.py b/src/shellgame/levels/sections/__init__.py deleted file mode 100644 index 812d5f2..0000000 --- a/src/shellgame/levels/sections/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Section-specific level implementations.""" diff --git a/src/shellgame/levels/sections/section0.py b/src/shellgame/levels/sections/section0.py deleted file mode 100644 index 62f9e5f..0000000 --- a/src/shellgame/levels/sections/section0.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Section 0: Introduction and Game Mechanics.""" - -from __future__ import annotations - -from shellgame.levels.base import Level -from shellgame.levels.collector import Section -from shellgame.levels.completion import Completion, ExactAnswer - -section = Section(0) - - -@section.level(0) -class IntroLevel(Level): - is_intro = True - title = "Vítejte v ShellGame" - instructions_file = "section0_intro.md" - hints = ["Přečtěte si úvod a pokračujte stisknutím Enter."] - start_directory = "" - success_message = "Vítejte ve hře!" - - -@section.level(1) -class WarmupPasswordLevel(Level): - title = "Zahřívací kolo" - instructions = """ - ### Zahřívací kolo - - Toto je první testovací úkol, abychom si ověřili, že vše funguje. - - ### Úkol - Pro postup do první sekce stačí odevzdat heslo `start`. - - Odevzdejte pomocí: `shellgame submit start` - """ - hints = [ - "Heslo je uvedeno přímo v zadání úkolu výše.", - "Zadejte příkaz 'shellgame submit' následovaný tímto heslem.", - "Opravdu jen napište: shellgame submit start", - ] - start_directory = "" - completion = Completion(answer=ExactAnswer("start", case_sensitive=False)) - success_message = "Správně! Takhle se odevzdává každý úkol: `shellgame submit` a vaše odpověď." diff --git a/src/shellgame/levels/sections/section1.py b/src/shellgame/levels/sections/section1.py deleted file mode 100644 index c841a10..0000000 --- a/src/shellgame/levels/sections/section1.py +++ /dev/null @@ -1,856 +0,0 @@ -"""Navigation and path manipulation levels.""" - -from __future__ import annotations - -import re -from pathlib import Path - -from typing_extensions import override - -from shellgame.levels.base import Level, block_cd -from shellgame.levels.cdpolicy import ( - CdEvidence, - CdPolicy, - FromDirectory, - RequireAbsolutePath, - RequireEvidence, - RequireExactCommand, - cd_marker, -) -from shellgame.levels.collector import Section -from shellgame.levels.completion import ( - AtDirectory, - AtHome, - Completion, - Evidence, - ExactAnswer, -) -from shellgame.levels.fixture import FileFixture, WorkspaceFixture -from shellgame.levels.solution import Chdir, GoHome, PerformCd, RecordEvidence, Solution, WalkHome -from shellgame.markers import MarkerManager -from shellgame.messages import Messages -from shellgame.paths import WORKSPACE_ROOT -from shellgame.protocols import CdHookCallback, GameStateProtocol, ValidationResult - -section = Section( - 1, - root="level-1", - fixture=WorkspaceFixture( - directories=("alpha", "delta", "gamma", "patterns", "patterns/omega"), - files=( - FileFixture("alpha/inside.txt", "first step", overwrite=False), - FileFixture("delta/single.dat", "momentum gained", overwrite=False), - FileFixture("patterns/data.txt", overwrite=False), - FileFixture("patterns/dog.md", overwrite=False), - FileFixture("patterns/drama.log", overwrite=False), - FileFixture("patterns/zebra.txt", overwrite=False), - ), - ), -) - - -@section.level(0) -class Section1Intro(Level): - is_intro = True - title = "Navigace" - instructions_file = "section1_intro.md" - hints = ["Přečtěte si úvod a pokračujte stisknutím Enter."] - start_directory = WORKSPACE_ROOT - success_message = "Jdeme na to!" - - -@section.level(1) -class PwdLevel(Level): - solution = Solution(steps=(RecordEvidence(MarkerManager.PWD_USED),), answer="level-1") - title = "Aktuální umístění" - instructions = """ - ### Cíl - Zjistěte název aktuálního adresáře. - - ### Příkazy - - `pwd` - vypíše celou cestu k aktuálnímu adresáři - - ### Úkol - 1. Spusťte `pwd` - 2. Odevzdejte název posledního adresáře v cestě (basename) - - ### Příklad - Cesta: `/home/student/dokumenty` -> Odpověď: `dokumenty` - - ### Odevzdání - `shellgame submit <název>` - """ - hints = [ - "Příkaz 'pwd' (Print Working Directory) vám ukáže, kde jste. Zkuste ho!", - "Podívejte se na výstup 'pwd'. Co je za posledním lomítkem?", - "Basename je poslední část cesty. Z cesty '/home/student/dokumenty' byste odevzdali 'dokumenty'.", - ] - start_directory = "" - completion = Completion( - answer=ExactAnswer("level-1"), - requirements=(Evidence(MarkerManager.PWD_USED, Messages.L1_1_USE_PWD_FIRST),), - ) - success_message = "Správně! `pwd` vypisuje celou cestu; poslední část za lomítkem je adresář, ve kterém stojíte." - - -@section.level(2) -class LsLevel(Level): - solution = Solution(answer="delta") - title = "Soubory, nebo adresáře?" - instructions = """ - ### Cíl - Nejprve si prohlédněte běžný výpis. Potom v nápovědě zjistěte, jak zobrazit - podrobnosti, a rozlište soubor od adresáře bez spoléhání na barvy terminálu. - - ### Příkazy - - `ls` - vypíše názvy položek v aktuálním adresáři - - `ls --help` - zobrazí stručnou nápovědu k příkazu `ls` - - `man ls` - otevře podrobný manuál; ukončíte ho klávesou `q` - - ### Úkol - 1. Spusťte nejprve obyčejné `ls` - 2. V nápovědě najděte přepínač pro dlouhý (podrobný) výpis a použijte ho - 3. V podrobném výpisu značí první znak `d` adresář a `-` běžný soubor - 4. Najděte **adresář**, jehož název začíná na `d` a končí na `a` - 5. Odevzdejte jeho název - - ### Odevzdání - `shellgame submit <hodnota>` - """ - hints = [ - "Běžný výpis ukáže názvy, ale bez barev z něj typ položky spolehlivě nepoznáte.", - "Zjistěte v nápovědě k příkazu `ls`, který přepínač zapíná dlouhý výpis.", - "Otevřete nápovědu pomocí `ls --help` nebo `man ls` a hledejte výraz 'long listing'.", - "Spusťte `ls -l`: řádek adresáře začíná `d`, řádek běžného souboru `-`.", - ] - start_directory = "" - fixture = WorkspaceFixture( - clean=("data", "dog", "dome"), - directories=("delta", "dome"), - files=(FileFixture("data"), FileFixture("dog")), - ) - success_message = "Správně! Podle prvního znaku výpisu `ls -l` jste rozlišili adresář od souboru." - completion = Completion( - answer=ExactAnswer( - "delta", - mistakes={ - "data": "'data' odpovídá názvem, ale není to adresář: jeho řádek v 'ls -l' začíná '-'.", - "dome": "'dome' je adresář, ale jeho název nekončí na 'a'.", - }, - required_message="Musíte zadat název adresáře: shellgame submit <hodnota>", - ), - ) - - @override - def validate(self, answer: str | None, state: GameStateProtocol) -> ValidationResult: - normalized = answer.strip().rstrip("/") if answer is not None else None - success, msg = super().validate(normalized, state) - if success or normalized is None: - return success, msg - - # Pattern-shape diagnostics that cannot be expressed as a fixed mistake map. - if normalized.startswith("d") and not normalized.endswith("a"): - return ( - False, - f"'{normalized}' začíná na 'd', ale nekončí na 'a'. Hledáme vzor d...a.", - ) - if normalized.endswith("a") and not normalized.startswith("d"): - return ( - False, - f"'{normalized}' končí na 'a', ale nezačíná na 'd'. Hledáme vzor d...a.", - ) - - return False, msg - - -@section.level(3) -class ExtensionLevel(Level): - solution = Solution(steps=(Chdir("alpha"),), answer="inside") - title = "Vstup a hlášení (Koncept přípony)" - instructions = """ - ### Cíl - Identifikujte soubor bez přípony. - - ### Příkazy - - `cd <název>` - změnit adresář - - ### Úkol - 1. Jděte do `alpha` (`cd alpha`) - 2. Najděte soubor uvnitř (`ls`) - 3. Odevzdejte název souboru **BEZ** přípony (vynechte poslední tečku a část za ní) - """ - hints = [ - "Použijte 'cd alpha' pro vstup do adresáře alpha.", - "Vypište obsah pomocí 'ls'. Uvidíte soubor s příponou '.txt'.", - "Odevzdejte název tohoto souboru, ale vynechejte část '.txt'.", - "Příklad: Pokud je soubor 'data.csv', odevzdejte 'data'.", - ] - start_directory = "" - success_message = "Správně! Správně jste odstranili příponu." - completion = Completion( - answer=ExactAnswer( - "inside", - mistakes={ - "inside.txt": Messages.L1_3_INCLUDED_EXTENSION, - "alpha": ( - "'alpha' je název adresáře, ne souboru uvnitř. " - "Nejdřív vstupte do alpha a podívejte se, co je uvnitř." - ), - }, - ), - requirements=(AtDirectory("alpha"),), - ) - - @override - def validate(self, answer: str | None, state: GameStateProtocol) -> ValidationResult: - success, msg = super().validate(answer, state) - if success: - return True, msg - - if answer and "." in answer and msg == Messages.INCORRECT: - return False, Messages.L1_3_INCLUDED_EXTENSION - - return False, msg - - -@section.level(4) -class CdUpLevel(Level): - solution = Solution(steps=(PerformCd(".."),), answer="level-1") - title = "Návrat na základnu" - instructions = """ - ### Cíl - Vraťte se o úroveň výše. - - ### Příkazy - - `cd ..` - jít o úroveň výše - - ### Úkol - 1. Jděte do nadřazeného adresáře (`cd ..`) - 2. Odevzdejte název tohoto adresáře - """ - hints = [ - "Dvě tečky '..' označují nadřazený (rodičovský) adresář.", - "Použijte příkaz 'cd ..' pro přesun o jednu úroveň výše.", - "Spusťte 'pwd' a odevzdejte jen název posledního adresáře (část za posledním '/').", - ] - start_directory = "alpha" - completion = Completion( - answer=ExactAnswer("level-1"), - requirements=( - AtDirectory(""), - CdEvidence("Použijte pro návrat přesně příkaz `cd ..`."), - ), - ) - cd_policy = CdPolicy(rules=(RequireExactCommand("..", "Použijte přesně příkaz 'cd ..'."),)) - success_message = "Správně! `..` neodkazuje na konkrétní jméno, ale vždy na rodiče toho adresáře, kde právě jste." - - -@section.level(5) -class DeepDiveLevel(Level): - solution = Solution(steps=(Chdir("gamma/deep/a/b/c"),), answer="c") - title = "Hluboký ponor" - instructions = """ - ### Cíl - Sestupte hluboko do adresářové struktury. - - ### 💡 Tip: Klávesa Tab (doplňování názvů) - Dlouhé cesty nemusíte vypisovat celé písmeno po písmenu! - Napište `cd g` a stiskněte **Tab** — shell název `gamma/` doplní za vás. - Pak napište `d` a znovu stiskněte **Tab**. Klávesa Tab je v terminálu váš největší pomocník proti překlepům. - - ### Úkol - 1. Začínáte v adresáři `level-1` - 2. Jděte do `gamma/deep/a/b/c/` (v adresáři `level-1`) — vyzkoušejte klávesu Tab! - 3. Odevzdejte název aktuálního adresáře - """ - hints = [ - "Použijte 'cd' pro vstup do adresářů. Vyzkoušejte klávesu Tab pro automatické doplňování!", - "Můžete jít postupně: cd gamma, cd deep, cd a...", - "Nebo najednou: cd gamma/deep/a/b/c (napište 'cd g' a stiskněte Tab)", - "Po přesunu ověřte polohu příkazem 'pwd' a odevzdejte poslední část cesty.", - ] - start_directory = "" - completion = Completion( - answer=ExactAnswer("c"), - requirements=(AtDirectory("gamma/deep/a/b/c"),), - ) - fixture = WorkspaceFixture(directories=("gamma/deep/a/b/c",)) - success_message = "Správně! Lomítka spojují kroky do jedné relativní cesty — jeden `cd` zvládne celou větev." - - -@section.level(6) -class MultiLevelAscentLevel(Level): - solution = Solution(steps=(PerformCd("../../.."),)) - title = "Víceúrovňový výstup" - instructions = """ - ### Cíl - Vystoupejte o více úrovní najednou. - - ### Příkazy - - `cd ../../..` - jít o 3 úrovně výše - - ### Úkol - 1. ShellGame vás na začátku umístí do správného adresáře (nemusíte řešit, kde jste skončili minule). - 2. Vraťte se o 3 úrovně výše **JEDNÍM** příkazem - 3. V cíli spusťte pouze `shellgame submit` - """ - hints = [ - "Cesty lze řetězit: každé '..' představuje posun o jednu úroveň nahoru.", - "Pro posun o tři úrovně najednou spojte tři segmenty: 'cd ../../..'.", - "Polohu si ověřte příkazem 'pwd'. Pokud jste správně, spusťte jen 'shellgame submit'.", - ] - start_directory = "gamma/deep/a/b/c" - success_message = "Správně! Úspěšně jste vystoupali o 3 úrovně." - completion = Completion( - requirements=( - AtDirectory("gamma/deep"), - CdEvidence("Vraťte se o tři úrovně jedním příkazem `cd ../../..`."), - ), - ) - fixture = WorkspaceFixture(directories=("gamma/deep/a/b/c",)) - cd_policy = CdPolicy(rules=(RequireExactCommand("../../..", "Použijte jeden příkaz 'cd ../../..'."),)) - - -_GO_TO_DIR = re.compile(r"^GO_TO_DIR_(.+)$") -_GO_UP_THEN = re.compile(r"^GO_UP_(\d+)_THEN_GO_TO_(.+)$") -_GO_UP_ONLY = re.compile(r"^GO_UP_(\d+)$") - - -def resolve_maze_instruction(name: str, cwd: Path) -> Path | None: - """Where a `GO_*` filename would take the player from ``cwd``.""" - if match := _GO_TO_DIR.fullmatch(name): - return cwd / match.group(1) - if match := _GO_UP_THEN.fullmatch(name): - target = cwd - for _ in range(int(match.group(1))): - target = target.parent - return target / match.group(2) - if match := _GO_UP_ONLY.fullmatch(name): - target = cwd - for _ in range(int(match.group(1))): - target = target.parent - return target - return None - - -def maze_marker_text(name: str) -> str: - """Short note so `cat` is not a blank page. The filename remains the instruction.""" - if _GO_TO_DIR.fullmatch(name) or _GO_UP_THEN.fullmatch(name) or _GO_UP_ONLY.fullmatch(name): - return "Instrukce je v názvu tohoto souboru. Řiďte se jménem, ne obsahem.\n" - if name == "YOU_ARE_NOT_SUPPOSED_TO_BE_HERE": - return ( - "Tady nemáte být. Vraťte se a sledujte soubory začínající na GO_.\n" - "Ztratili jste se? Příkaz 'shellgame reset' vás vrátí na začátek bludiště (maze/entry).\n" - ) - if name == "VICTORY.marker": - return ( - "Konečně skutečný cíl (opravdu_final_v2_FINAL)! Level je automaticky splněn.\n" - "Případně můžete odevzdat ručně příkazem: shellgame submit\n" - ) - return "" - - -_MAZE_TRAP = "YOU_ARE_NOT_SUPPOSED_TO_BE_HERE" -# The long route is deliberate practice: repeated inspection and navigation, -# several local backtracks, compound upward moves, branching, and decoys. -_MAZE_STRUCTURE: dict[str, tuple[str, ...]] = { - # Entrance area - "entry": ("GO_TO_DIR_hall", "dungeon", "courtyard"), - "entry/courtyard": (_MAZE_TRAP,), - "entry/dungeon": (_MAZE_TRAP,), - "entry/hall": ("GO_TO_DIR_nexus", "alcove", "side_door"), - "entry/hall/alcove": (_MAZE_TRAP,), - "entry/hall/side_door": ("GO_UP_1",), - # Nexus hub - "entry/hall/nexus": ("GO_TO_DIR_passages", "sanctum", "rotunda", "archives"), - "entry/hall/nexus/sanctum": (_MAZE_TRAP,), - "entry/hall/nexus/archives": (_MAZE_TRAP,), - "entry/hall/nexus/rotunda": ("GO_UP_1",), - # Passages branch - "entry/hall/nexus/passages": ("GO_TO_DIR_tunnel", "gallery", "shaft"), - "entry/hall/nexus/passages/gallery": (_MAZE_TRAP,), - "entry/hall/nexus/passages/shaft": ("GO_UP_1",), - "entry/hall/nexus/passages/tunnel": ("GO_TO_DIR_cavern", "crevice"), - "entry/hall/nexus/passages/tunnel/crevice": (_MAZE_TRAP,), - "entry/hall/nexus/passages/tunnel/cavern": ("GO_TO_DIR_depths", "grotto", "abyss"), - "entry/hall/nexus/passages/tunnel/cavern/grotto": (_MAZE_TRAP,), - "entry/hall/nexus/passages/tunnel/cavern/abyss": (_MAZE_TRAP,), - "entry/hall/nexus/passages/tunnel/cavern/depths": ( - "GO_UP_2_THEN_GO_TO_catacombs", - "echoes.txt", - ), - # Catacombs branch - "entry/hall/nexus/passages/tunnel/catacombs": ("GO_TO_DIR_vault", "ossuary", "crypts"), - "entry/hall/nexus/passages/tunnel/catacombs/ossuary": (_MAZE_TRAP,), - "entry/hall/nexus/passages/tunnel/catacombs/crypts": ("GO_UP_1",), - "entry/hall/nexus/passages/tunnel/catacombs/vault": ( - "GO_TO_DIR_chamber", - "iron_cell", - "sepulcher", - ), - "entry/hall/nexus/passages/tunnel/catacombs/vault/iron_cell": (_MAZE_TRAP,), - "entry/hall/nexus/passages/tunnel/catacombs/vault/sepulcher": (_MAZE_TRAP,), - "entry/hall/nexus/passages/tunnel/catacombs/vault/chamber": ( - "GO_UP_2_THEN_GO_TO_labyrinth", - "relic.txt", - ), - # Labyrinth and final sequence - "entry/hall/nexus/passages/tunnel/catacombs/labyrinth": ( - "GO_TO_DIR_corridor", - "dead_end", - "ruins", - ), - "entry/hall/nexus/passages/tunnel/catacombs/labyrinth/dead_end": (_MAZE_TRAP,), - "entry/hall/nexus/passages/tunnel/catacombs/labyrinth/ruins": (_MAZE_TRAP,), - "entry/hall/nexus/passages/tunnel/catacombs/labyrinth/corridor": ( - "GO_TO_DIR_shrine", - "false_exit", - "mist", - ), - "entry/hall/nexus/passages/tunnel/catacombs/labyrinth/corridor/false_exit": (_MAZE_TRAP,), - "entry/hall/nexus/passages/tunnel/catacombs/labyrinth/corridor/mist": ("GO_UP_1",), - "entry/hall/nexus/passages/tunnel/catacombs/labyrinth/corridor/shrine": ( - "GO_TO_DIR_final", - "mirage", - "shadow", - ), - "entry/hall/nexus/passages/tunnel/catacombs/labyrinth/corridor/shrine/mirage": (_MAZE_TRAP,), - "entry/hall/nexus/passages/tunnel/catacombs/labyrinth/corridor/shrine/shadow": (_MAZE_TRAP,), - "entry/hall/nexus/passages/tunnel/catacombs/labyrinth/corridor/shrine/final": ( - "GO_TO_DIR_final_v2", - "decoy_exit", - "draft.txt", - ), - "entry/hall/nexus/passages/tunnel/catacombs/labyrinth/corridor/shrine/final/decoy_exit": (_MAZE_TRAP,), - "entry/hall/nexus/passages/tunnel/catacombs/labyrinth/corridor/shrine/final/final_v2": ( - "GO_TO_DIR_final_final", - "abandoned_branch", - "old.bak", - ), - ("entry/hall/nexus/passages/tunnel/catacombs/labyrinth/corridor/shrine/final/final_v2/abandoned_branch"): ( - _MAZE_TRAP, - ), - "entry/hall/nexus/passages/tunnel/catacombs/labyrinth/corridor/shrine/final/final_v2/final_final": ( - "GO_TO_DIR_opravdu_final_v2_FINAL", - "fake_end", - ), - ("entry/hall/nexus/passages/tunnel/catacombs/labyrinth/corridor/shrine/final/final_v2/final_final/fake_end"): ( - _MAZE_TRAP, - ), - ( - "entry/hall/nexus/passages/tunnel/catacombs/labyrinth/corridor/shrine/" - "final/final_v2/final_final/opravdu_final_v2_FINAL" - ): ("VICTORY.marker",), -} - - -def _is_maze_file(item: str) -> bool: - return item.startswith("GO_") or item == _MAZE_TRAP or item.endswith((".txt", ".md", ".marker", ".bak")) - - -def _maze_fixture() -> WorkspaceFixture: - directories: list[str] = [] - files: list[FileFixture] = [] - seen_files: set[str] = set() - - def add_file(relative: str, name: str) -> None: - if relative in seen_files: - return - seen_files.add(relative) - files.append(FileFixture(relative, maze_marker_text(name))) - - for dir_path, contents in _MAZE_STRUCTURE.items(): - directories.append(f"maze/{dir_path}") - has_go = any(item.startswith("GO_") for item in contents) - for item in contents: - relative = f"maze/{dir_path}/{item}" - if _is_maze_file(item): - add_file(relative, item) - else: - directories.append(relative) - if not has_go and not any(item == "VICTORY.marker" for item in contents): - add_file(f"maze/{dir_path}/{_MAZE_TRAP}", _MAZE_TRAP) - - return WorkspaceFixture( - directories=tuple(dict.fromkeys(directories)), - files=tuple(files), - clean=("maze",), - ) - - -@section.level(7) -class MazeLevel(Level): - _SANCTUARY = ( - "maze/entry/hall/nexus/passages/tunnel/catacombs/labyrinth/corridor/shrine" - "/final/final_v2/final_final/opravdu_final_v2_FINAL" - ) - solution = Solution( - steps=tuple( - Chdir(step) - for step in ( - "maze/entry", - "maze/entry/hall", - "maze/entry/hall/nexus", - "maze/entry/hall/nexus/passages", - "maze/entry/hall/nexus/passages/tunnel", - "maze/entry/hall/nexus/passages/tunnel/cavern", - "maze/entry/hall/nexus/passages/tunnel/cavern/depths", - "maze/entry/hall/nexus/passages/tunnel/catacombs", - "maze/entry/hall/nexus/passages/tunnel/catacombs/vault", - "maze/entry/hall/nexus/passages/tunnel/catacombs/vault/chamber", - "maze/entry/hall/nexus/passages/tunnel/catacombs/labyrinth", - "maze/entry/hall/nexus/passages/tunnel/catacombs/labyrinth/corridor", - "maze/entry/hall/nexus/passages/tunnel/catacombs/labyrinth/corridor/shrine", - "maze/entry/hall/nexus/passages/tunnel/catacombs/labyrinth/corridor/shrine/final", - "maze/entry/hall/nexus/passages/tunnel/catacombs/labyrinth/corridor/shrine/final/final_v2", - ( - "maze/entry/hall/nexus/passages/tunnel/catacombs/labyrinth/corridor/shrine/" - "final/final_v2/final_final" - ), - ( - "maze/entry/hall/nexus/passages/tunnel/catacombs/labyrinth/corridor/shrine/" - "final/final_v2/final_final/opravdu_final_v2_FINAL" - ), - ) - ), - ) - title = "Navigace v bludišti" - instructions = """ - ### Cíl - Projděte bludištěm podle instrukcí. - - ### Pravidla - - Start: `level-1/maze/entry/` - - Instrukce je v **názvu** souboru `GO_…` (příkaz `ls`) - - `GO_TO_DIR_x` → `cd x` - - `GO_UP_N` → jděte pomocí `cd ..` o N úrovní výše - - `GO_UP_N_THEN_GO_TO_x` → jděte o N úrovní výše a potom do `x` - - Během bludiště nemůžete odejít mimo `maze/` - - ### Úkol - 1. Ze startu sledujte názvy souborů `GO_…` - 2. Bludiště je záměrně delší: procvičíte opakované `ls`, sestup, návraty i přechod k sourozencům - 3. Jakmile vstoupíte do skutečného cíle, level se automaticky splní - (případně můžete odevzdat `shellgame submit`) - - Pokud se v bludišti ztratíte, příkaz `shellgame reset` vás vrátí zpět na začátek (`maze/entry`). - """ - hints = [ - "Sledujte pouze názvy souborů začínající na 'GO_'. Vypište je pomocí 'ls'.", - "U instrukce 'GO_UP_2_THEN_GO_TO_catacombs' se vraťte o dvě úrovně a vstupte do catacombs.", - "Spojený návrat lze zapsat jako 'cd ../..'; potom pokračujte příkazem 'cd catacombs'.", - "Ignorujte položky, které nezačínají na 'GO_'; jsou to odbočky nebo pasti.", - "Pokud se ztratíte, příkaz 'shellgame reset' vás vrátí na start.", - ] - start_directory = "maze/entry" - fixture = _maze_fixture() - success_message = "Správně! Prošli jste bludištěm až do skutečného cíle!" - completion = Completion(requirements=(AtDirectory(_SANCTUARY),)) - - @property - @override - def hooks(self) -> dict[str, CdHookCallback]: - return {"cd": self._handle_cd} - - def _handle_cd(self, *, target: str | None, pwd: str | None, post_move: bool, state: GameStateProtocol) -> None: - maze_root = (self.section_path(state.workspace) / "maze").resolve() - - if not post_move: - # Pre-move: Guard player from leaving level-1/maze before completion - if self.cd_enforcement_lifted(cd_marker(self.id), target=target, pwd=pwd, state=state): - return - - if not target: - # `cd` without arguments attempts to go home (outside the maze) - block_cd(self.id, "Nemůžete opustit bludiště. Pokračujte v navigaci uvnitř maze/.") - - dest = Path(target).expanduser() - if not dest.is_absolute(): - base = Path(pwd) if pwd else Path.cwd() - dest = base / dest - - try: - dest_resolved = dest.resolve() - except (OSError, RuntimeError): - return - - if not dest_resolved.is_relative_to(maze_root): - block_cd(self.id, "Nemůžete opustit bludiště před jeho dokončením.") - else: - # Post-move: Check if user reached true final room for autowin - if not pwd: - return - - dest_resolved = Path(pwd).resolve() - sanctuary_resolved = (self.section_path(state.workspace) / self._SANCTUARY).resolve() - if dest_resolved == sanctuary_resolved: - MarkerManager.from_state(state).create(cd_marker(self.id)) - - -@section.level(8) -class AbsoluteCdLevel(Level): - solution = Solution(steps=(PerformCd("absolute-target", absolute=True),)) - title = "Skok absolutní cestou" - instructions = """ - ### Cíl - Použijte absolutní cestu. - - ### Příkazy - - `cd /cesta` - absolutní cesta (od kořene) - - ### Úkol - 1. Začínáte v `level-1`. Zjistěte celou cestu příkazem `pwd` - 2. Použijte **JEDEN** příkaz `cd` s absolutní cestou do podadresáře `absolute-target` - 3. V cíli spusťte pouze `shellgame submit` - """ - hints = [ - "Absolutní cesty začínají na /. Použijte 'pwd' pro zobrazení vaší plné cesty.", - "K celé cestě vypsané příkazem 'pwd' na startu připojte '/absolute-target'.", - "Po přesunu ověřte polohu příkazem 'pwd' a spusťte jen 'shellgame submit'.", - "Za 'cd' napište celou sestavenou cestu od /. Pokud obsahuje mezery, uzavřete ji do uvozovek.", - ] - start_directory = "" - success_message = "Správně! Dostali jste se sem absolutní cestou." - completion = Completion( - requirements=( - AtDirectory("absolute-target"), - CdEvidence(Messages.ABSOLUTE_CD_NOT_USED), - ) - ) - fixture = WorkspaceFixture(files=(FileFixture("absolute-target/PLACEHOLDER.answer"),)) - cd_policy = CdPolicy( - rules=( - RequireAbsolutePath( - "Musíte použít absolutní cestu (začínající na /).", - missing_message="Musíte zadat cestu.", - ), - ) - ) - - -@section.level(9) -class HomeWalkLevel(Level): - solution = Solution(steps=(WalkHome(),)) - title = "Cesta z kořene domů" - instructions = """ - ### Cíl - Zrekonstruujte cestu domů. - - ### Příkazy - - `cd /` - jít do kořene - - `echo $HOME` - zobrazit cestu domů - - ### Úkol - 1. Jděte do kořene (`cd /`) - 2. Zjistěte cestu domů (`echo $HOME`) - 3. Jděte domů krok za krokem (každý segment zvlášť) - 4. Nakonec použijte jen `shellgame submit` - - Poznámka: Váš domovský adresář (`$HOME`) leží mimo herní pracovní prostor. - """ - hints = [ - "Zjistěte cestu k domovu pomocí 'echo $HOME'.", - "Začněte v / a vstupujte do každého adresáře v cestě jeden po druhém (bez přeskakování).", - "Tip: pokud je HOME třeba /home/ada, udělejte: cd / ; cd home ; cd ada.", - "V tomhle levelu neodevzdáváte textovou odpověď – důležitá je správná sekvence `cd`.", - ] - start_directory = WORKSPACE_ROOT - reset_markers = (MarkerManager.LEVEL1_9_CD_WALK_PROGRESS,) - success_message = "Správně! Došli jste domů krok za krokem." - completion = Completion( - requirements=( - AtHome(), - Evidence(MarkerManager.LEVEL1_9_CD_WALK_COMPLETED, Messages.CD_WALK_NOT_COMPLETED), - ) - ) - - @property - @override - def hooks(self) -> dict[str, CdHookCallback]: - return {"cd": self._handle_cd} - - def _handle_cd( # noqa: PLR0912 - self, *, target: str | None, pwd: str | None, post_move: bool, state: GameStateProtocol - ) -> None: - markers = MarkerManager.from_state(state) - - if not post_move: - # Level 1.9 Pre-move: Enforce step-by-step (no jumps) - if self.cd_enforcement_lifted( - MarkerManager.LEVEL1_9_CD_WALK_COMPLETED, target=target, pwd=pwd, state=state - ): - return - - if not target: - # cd without args -> jump home -> forbidden - block_cd(self.id, "Skoky nejsou povoleny. Jděte krok za krokem.") - - if target == "/": - return # Allowed to start - - # Check for multi-segment paths - # We allow "dir" or "dir/" but not "dir/subdir" or "/dir" - cleaned = target.rstrip("/") - - if target.startswith("/"): - block_cd(self.id, "Absolutní skoky nejsou povoleny (kromě cd /).") - - if "/" in cleaned: - block_cd(self.id, "Cestujte po jednom segmentu (adresáři).") - - else: - # Level 1.9 Post-move: Track step-by-step walk from root to home - if not pwd: - return - - current_path = Path(pwd) - - # If at root, start tracking (reset) - if str(current_path) == "/": - markers.create(MarkerManager.LEVEL1_9_CD_WALK_PROGRESS, "/") - return - - # If we have progress, check if this step is valid - progress = markers.read(MarkerManager.LEVEL1_9_CD_WALK_PROGRESS) - if not progress: - return - - lines = progress.strip().splitlines() - if not lines: - return - - last_path = Path(lines[-1]) - - # Valid step: moving from parent to child (one level down) - if current_path.parent == last_path: - # Append new path - new_progress = progress + "\n" + str(current_path) - markers.create(MarkerManager.LEVEL1_9_CD_WALK_PROGRESS, new_progress) - - # Check completion - if current_path == Path.home(): - markers.create(MarkerManager.LEVEL1_9_CD_WALK_COMPLETED) - - elif current_path == last_path: - # No-op (stayed in same dir) - pass - else: - # Invalid step (jumped or went up/sideways), reset progress - markers.remove(MarkerManager.LEVEL1_9_CD_WALK_PROGRESS) - - -@section.level(10) -class HomeCheckLevel(Level): - solution = Solution(steps=(GoHome(),), answer=Path.home().name) - title = "Zkratka pro domov (~)" - instructions = """ - ### Cíl - Naučte se používat zkratku `~` (vlnovku) pro rychlý návrat do domovského adresáře. - - ### Zkratka pro domov: `~` (vlnovka / tilda) - V předchozím levelu jste šli do domovského adresáře krok za krokem. V běžné práci - však celou cestu psát nemusíte: - - Znak `~` (vlnovka) je v shellu zkratka pro váš domovský adresář (`$HOME`). - - Příkaz `cd ~` vás okamžitě přenese domů, ať se nacházíte kdekoliv v systému. - - Zkratku lze použít i v cestách, např. `cd ~/dokumenty`. - - *(Tip: do domovského adresáře vás přenese i samotný příkaz `cd` bez parametrů.)* - - ### 💡 Jak napsat znak `~` na klávesnici - - **Česká klávesnice**: `Pravý Alt` (AltGr) + klávesa `+` (obvykle vpravo nahoře; - po stisku může být potřeba stisknout mezerník). - - **Anglická klávesnice**: `Shift` + klávesa pod `Esc` (vlevo nahoře). - - ### Úkol - 1. Začínáte v herním prostoru (`level-1`) - 2. Přejděte do domovského adresáře pomocí zkratky: `cd ~` - 3. Příkazem `pwd` ověřte, kde se nacházíte - 4. Odevzdejte název vašeho domovského adresáře (poslední část cesty z `pwd`) - - ### Odevzdání - `shellgame submit <název>` - """ - hints = [ - ( - "Znak '~' (vlnovka) je zkratka shellu pro domovský adresář ($HOME). " - "Příkaz 'cd ~' vás přenese domů odkudkoliv." - ), - "Na české klávesnici napíšete '~' pomocí Pravého Alt (AltGr) + klávesy '+'.", - "Po přesunu použijte 'pwd' pro zjištění plné cesty a odevzdejte pouze její poslední část (basename).", - "Pokud 'pwd' ukáže např. '/home/jan', odevzdáte 'jan' příkazem 'shellgame submit jan'.", - ] - start_directory = WORKSPACE_ROOT - completion = Completion( - answer=ExactAnswer(Path.home().name), - requirements=(AtHome(),), - ) - success_message = ( - "Správně! Znak `~` (vlnovka) se v shellu vždy rozbalí na váš domovský adresář, ať stojíte kdekoliv v systému." - ) - - -#: The `1.11` suffix is intentionally unused: a level was retired after IDs had -#: already been persisted in player saves, and renumbering `1.12` would -#: invalidate them. Suffixes are stable identifiers, not positions. -@section.level(12) -class SummaryLevel(Level): - solution = Solution( - steps=( - RecordEvidence(MarkerManager.PWD_USED), - Chdir("gamma/deep/a/b/c"), - PerformCd("../../../.."), - ), - ) - title = "Souhrn" - instructions = """ - ### Výzva: Otestujte své dovednosti! - - Ukažte, co jste se naučili v této sekci. Proveďte následující kroky: - - ### Úkol - 1. Začínáte v `level-1`. Zjistěte svou aktuální polohu (`pwd`) - 2. Přejděte do adresáře `gamma/deep/a/b/c` - 3. Vraťte se o 4 úrovně výše jedním příkazem - 4. V cíli spusťte pouze `shellgame submit` - - ### Shrnutí příkazů - ``` - pwd → Kde jsem? - ls → Co tu je? - cd adresář → Vstup do adresáře - cd .. → O úroveň výše - cd ../.. → O více úrovní výše - cd /cesta → Absolutní cesta - cd ~ → Domů - ``` - - ### Odevzdání - `shellgame submit` - """ - hints = [ - "Nejdřív si pomocí 'pwd' potvrďte výchozí polohu a cestu rozdělte na jednotlivé adresáře.", - ( - "Při návratu spočítejte, kolik segmentů musíte z adresáře 'c' odstranit. " - "Každý segment '..' znamená jednu úroveň." - ), - "Použijte `cd gamma/deep/a/b/c` a potom `cd ../../../..`. Výsledek ověřte pomocí 'pwd'.", - ] - start_directory = "" - reset_markers = (MarkerManager.PWD_USED,) - success_message = "Výborně! Ovládáte základy navigace!" - completion = Completion( - requirements=( - AtDirectory("gamma"), - CdEvidence( - "Nejdřív použijte `pwd`, přejděte do `c` a vraťte se jedním příkazem o čtyři úrovně.", - ), - ), - ) - fixture = WorkspaceFixture(directories=("gamma/deep/a/b/c",)) - - cd_policy = CdPolicy( - scope=(FromDirectory("gamma/deep/a/b/c"),), - rules=( - RequireEvidence(MarkerManager.PWD_USED, "Nejdřív použijte příkaz 'pwd'."), - RequireExactCommand("../../../..", "Z adresáře 'c' použijte jeden příkaz 'cd ../../../..'."), - ), - ) diff --git a/src/shellgame/levels/sections/section10.py b/src/shellgame/levels/sections/section10.py deleted file mode 100644 index 62b720d..0000000 --- a/src/shellgame/levels/sections/section10.py +++ /dev/null @@ -1,356 +0,0 @@ -from __future__ import annotations - -from typing_extensions import override - -from shellgame.levels.base import Level -from shellgame.levels.collector import Section -from shellgame.levels.completion import ( - Completion, - Evidence, - ExactAnswer, - FileLineCount, - IntegerAnswer, - TextFileContent, - TupleAnswer, -) -from shellgame.levels.fixture import FileFixture, WorkspaceFixture -from shellgame.levels.solution import RecordFdEvidence, RunShell, Solution -from shellgame.markers import MarkerManager -from shellgame.protocols import GameStateProtocol - -section = Section(10, root="level-10") - -_BUGGY_SCRIPT = "#!/bin/bash\necho 'This is normal output'\necho 'This is an error message' >&2\n" -_MIXED_SCRIPT = """#!/bin/bash -echo "Line 1 - normal output" -echo "ERROR: Something went wrong" >&2 -echo "Line 2 - more output" -echo "ERROR: Another problem" >&2 -echo "Line 3 - final output" -""" - - -@section.level(0) -class SectionIntro(Level): - is_intro = True - title = "Sekce 10: Chybové výstupy" - instructions_file = "section10_intro.md" - hints = ["Přečtěte si úvod a pokračujte stisknutím Enter."] - success_message = "Jdeme na to!" - - -@section.level(1) -class StderrToFileLevel(Level): - solution = Solution(steps=(RunShell("./buggy.sh 2> errors.log"),), answer="errors.log") - title = "Přesměrování chyb" - instructions = """ - Standardní chybový výstup (stderr) používá deskriptor souboru 2. - Pro přesměrování pouze chyb použijte `2>`. - - ### Proč je to důležité - Při běhu programů často chcete zachytit chybové hlášky do logu, - zatímco normální výstup zobrazíte uživateli. Oddělení stdout a stderr - je klíčové pro diagnostiku problémů. - - ### Úkol - V adresáři je skript `buggy.sh`, který vypisuje normální text i chybové zprávy. - Spusťte ho a přesměrujte POUZE chybové zprávy do souboru `errors.log`. - - ### Příkazy - - `./script 2> soubor` - přesměruje stderr do souboru - - ### Odevzdání - Odevzdejte název vytvořeného souboru. - `shellgame submit <název>` - """ - hints = [ - "Běžný výstup jde na stdout (1), chyby na stderr (2). Jak přesměrujete jen dvojku?", - "Syntaxe je: příkaz 2> soubor. Zkuste to se skriptem buggy.sh.", - "Použijte './buggy.sh 2> errors.log'.", - ] - start_directory = "" - fixture = WorkspaceFixture( - files=(FileFixture("buggy.sh", _BUGGY_SCRIPT, mode=0o755),), - clean=("errors.log",), - ) - completion = Completion( - answer=ExactAnswer("errors.log"), - requirements=( - TextFileContent( - "errors.log", - excludes=("This is normal output",), - error_message="Soubor obsahuje i normální výstup (použili jste &> nebo chybí 2?).", - missing_message="Soubor neexistuje.", - ), - TextFileContent( - "errors.log", - contains=("This is an error message",), - error_message="Soubor neobsahuje očekávanou chybu.", - ), - ), - ) - success_message = "Správně! Soubor obsahuje pouze chyby." - - -@section.level(2) -class AppendStderrToFileLevel(Level): - solution = Solution(steps=(RunShell("./buggy.sh 2>> errors.log"),), answer="errors.log") - title = "Přidávání chyb" - instructions = """ - Stejně jako u normálního výstupu se i u chyb rozhodujete mezi dvěma operátory: - jeden soubor před zápisem vyprázdní, druhý zapisuje na jeho konec. - - ### Úkol - V souboru `errors.log` už je z minula záznam `Old error 1`. - Spusťte `buggy.sh` znovu tak, aby v souboru zůstal starý záznam i nová chyba. - - Než příkaz spustíte, rozmyslete si: který z obou operátorů soubor nejdřív - vyprázdní a co by se v takovém případě stalo s řádkem `Old error 1`? - - ### Příkazy - - `./script 2> soubor` - chybový výstup přepíše obsah souboru - - `./script 2>> soubor` - chybový výstup přidá na konec souboru - - ### Odevzdání - Odevzdejte název souboru. - `shellgame submit <název>` - """ - hints = [ - "Jaký je rozdíl mezi > a >>? Jeden přepisuje, druhý přidává.", - "Pro přidání chyb na konec použijte dvě šipky: 2>>", - "Použijte './buggy.sh 2>> errors.log'.", - ] - start_directory = "" - fixture = WorkspaceFixture( - files=( - FileFixture("buggy.sh", _BUGGY_SCRIPT, mode=0o755), - FileFixture("errors.log", "Old error 1\n"), - ) - ) - completion = Completion( - answer=ExactAnswer("errors.log"), - requirements=( - TextFileContent( - "errors.log", - contains=("Old error 1",), - error_message="Původní obsah zmizel (použili jste 2> místo 2>>?).", - missing_message="Soubor neexistuje.", - ), - TextFileContent( - "errors.log", - contains=("This is an error message",), - error_message="Soubor neobsahuje novou chybu.", - ), - ), - ) - success_message = "Správně! Dvojitá šipka zapisuje na konec, takže předchozí záznamy v logu zůstaly zachovány." - - -@section.level(3) -class AllOutputToFileLevel(Level): - solution = Solution(steps=(RunShell("./buggy.sh &> all_output.log"),), answer="all_output.log") - title = "Všechny výstupy" - instructions = """ - Někdy chcete zachytit VŠECHNO - normální výstup i chyby do jednoho souboru. - K tomu slouží `&>`. - - ### Proč je to užitečné - Při ladění skriptů nebo automatizaci často potřebujete kompletní log - všeho, co program vypsal - ať už to byla informace nebo chyba. - - ### Úkol - Spusťte `buggy.sh` a přesměrujte OBOJÍ (stdout i stderr) do `all_output.log`. - - ### Příkazy - - `./script &> soubor` - přesměruje stdout i stderr - - ### Odevzdání - Odevzdejte název souboru. - `shellgame submit <název>` - """ - hints = [ - "Ampersand (&) v tomto kontextu znamená 'obojí' - stdout i stderr.", - "Kombinace &> je zkratka pro přesměrování obou výstupů.", - "Použijte './buggy.sh &> all_output.log'.", - ] - start_directory = "" - fixture = WorkspaceFixture( - files=(FileFixture("buggy.sh", _BUGGY_SCRIPT, mode=0o755),), - clean=("all_output.log",), - ) - completion = Completion( - answer=ExactAnswer("all_output.log"), - requirements=( - TextFileContent( - "all_output.log", - contains=("This is normal output", "This is an error message"), - error_message=( - "Soubor neobsahuje oba typy výstupů. " - "Chybí-li normální výstup, přesměrovali jste jen chyby (2>). " - "Chybí-li chyby, zůstaly na obrazovce, protože samotné > bere jen stdout." - ), - missing_message="Soubor neexistuje.", - ), - ), - ) - success_message = "Správně! Máme všechno." - - -@section.level(4) -class DevNullLevel(Level): - solution = Solution(steps=(RecordFdEvidence(),), answer="/dev/null") - title = "Černá díra" - instructions = """ - `/dev/null` je speciální soubor, který zahodí všechno, co do něj pošlete. - Je užitečný pro umlčení hlučných příkazů. - - ### Proč je to užitečné - Některé příkazy vypisují spoustu informací, které nepotřebujete. - Místo zahlcení obrazovky je můžete "poslat do černé díry". - - ### Úkol - Spusťte `buggy.sh` a umlčte VŠECHNY výstupy (stdout i stderr) přesměrováním do `/dev/null`. - - ### Příkazy - - `./script &> /dev/null` - zahodí veškerý výstup - - ### Odevzdání - Odevzdejte název speciálního souboru, který jste použili. - `shellgame submit <název>` - """ - hints = [ - "Kam v Linuxu 'vyhodíte' data, která nechcete? Existuje speciální soubor...", - "Soubor /dev/null je jako černá díra - vše pohltí a nic nevrátí.", - "Použijte './buggy.sh &> /dev/null'.", - ] - start_directory = "" - fixture = WorkspaceFixture( - files=( - FileFixture( - "buggy.sh", - _BUGGY_SCRIPT + '"$SHELLGAME_FD_HOOK"\n', - mode=0o755, - ), - ) - ) - completion = Completion( - answer=ExactAnswer( - "/dev/null", - mistakes={ - "null": "Skoro. Odevzdejte celou cestu k tomu speciálnímu souboru, ne jen jeho název.", - "dev/null": "Téměř. Jde o absolutní cestu, začíná tedy lomítkem od kořenového adresáře.", - }, - ), - requirements=( - Evidence( - MarkerManager.LEVEL9_4_DEV_NULL, - "Spusťte `./buggy.sh` a přesměrujte stdout i stderr do `/dev/null`.", - ), - ), - ) - success_message = "Správně! Zápis do /dev/null se rovnou zahodí, takže příkaz umlčíte bez zakládání logu." - - @override - def record_fd_evidence( - self, - *, - stdout_target: str, - stderr_target: str, - state: GameStateProtocol, - ) -> None: - if stdout_target == "/dev/null" and stderr_target == "/dev/null": - MarkerManager.from_state(state).create(MarkerManager.LEVEL9_4_DEV_NULL) - - -@section.level(5) -class StreamsChallengeLevel(Level): - solution = Solution( - steps=(RunShell("./mixed.sh > output.log 2> errors.log"),), - answer="2,3", - ) - title = "Souhrn Sekce 10" - instructions = """ - ### Výzva: Oddělení streamů - - Ukažte, že umíte uložit stdout a stderr odděleně. - - ### Úkol - V `level-10/challenge` je skript `mixed.sh` který vypisuje: - - normální výstup na stdout - - chyby na stderr - - Spusťte skript **jednou** a současně uložte: - 1. pouze normální výstup do `output.log` - 2. pouze chyby do `errors.log` - - Odpovězte: kolik řádků má errors.log a kolik output.log? - Formát: `chyby,výstup` (např. `3,5`) - - ### Připomenutí - - `>` přesměruje standardní výstup. - - `2>` přesměruje chybový výstup. - - V této výzvě potřebujete oba proudy zachytit zvlášť. - - ### Odevzdání - `shellgame submit <chyby>,<výstup>` - """ - hints = [ - ( - "Chyby se zapisují na stderr (descriptor 2), standardní výstup na stdout (descriptor 1). " - "Každý proud může mít vlastní cíl." - ), - ( - "Za jeden příkaz lze zapsat přesměrování '>' i '2>'; pořadí zde nevadí, " - "protože oba proudy míří do různých souborů." - ), - "Spusťte './mixed.sh > output.log 2> errors.log'. Počty zjistěte pomocí 'wc -l errors.log output.log'.", - ] - start_directory = "challenge" - fixture = WorkspaceFixture( - files=(FileFixture("challenge/mixed.sh", _MIXED_SCRIPT, mode=0o755),), - clean=("challenge",), - ) - completion = Completion( - answer=TupleAnswer( - ( - IntegerAnswer( - 2, - error_message=( - "Počet chyb není správně. Zkontrolujte, že do errors.log směřuje pouze descriptor 2." - ), - invalid_message="Obě hodnoty musí být čísla.", - ), - IntegerAnswer( - 3, - error_message=( - "Počet normálních řádků není správně. " - "Zkontrolujte, že do output.log směřuje pouze standardní výstup." - ), - invalid_message="Obě hodnoty musí být čísla.", - ), - ), - format_message="Formát odpovědi: chyby,výstup (např. 3,5)", - ), - requirements=( - FileLineCount( - "challenge/errors.log", - 2, - ( - "errors.log neobsahuje přesně pouze chybový výstup skriptu. " - "Buď do něj spadl i standardní výstup (oba proudy míří do jednoho souboru), " - "nebo jste skript spustili víckrát a záznamy se nasčítaly." - ), - ), - FileLineCount( - "challenge/output.log", - 3, - ( - "output.log neobsahuje přesně pouze standardní výstup skriptu. " - "Buď v něm skončily i chyby (chybí samostatné přesměrování druhého proudu), " - "nebo jste skript spustili víckrát a záznamy se nasčítaly." - ), - ), - ), - ) - success_message = "Perfektní! Dokončili jste Sekci 10. Stdout a stderr jsou pro vás jako otevřená kniha!" diff --git a/src/shellgame/levels/sections/section11.py b/src/shellgame/levels/sections/section11.py deleted file mode 100644 index cab8892..0000000 --- a/src/shellgame/levels/sections/section11.py +++ /dev/null @@ -1,386 +0,0 @@ -from __future__ import annotations - -from shellgame.levels.base import Level -from shellgame.levels.collector import Section -from shellgame.levels.completion import ( - Completion, - ExactAnswer, - IntegerAnswer, - PathsMatch, - SuffixAnswer, - TextFileContent, - TupleAnswer, -) -from shellgame.levels.fixture import FileFixture, WorkspaceFixture -from shellgame.levels.solution import RunShell, Solution - -section = Section(11, root="level-11") - - -@section.level(0) -class SectionIntro(Level): - is_intro = True - title = "Sekce 11: Vyhledávání" - instructions_file = "section11_intro.md" - hints = ["Přečtěte si úvod a pokračujte stisknutím Enter."] - success_message = "Jdeme na to!" - - -@section.level(1) -class GrepConfigLineToFileLevel(Level): - solution = Solution( - steps=(RunShell("grep ACTIVE_PROFILE config.txt > profile.txt"),), - answer="profile.txt", - ) - title = "Hledání v souboru (grep)" - instructions = """ - ### Cíl - Najděte řádek obsahující `ACTIVE_PROFILE` v souboru `config.txt` a uložte ho do `profile.txt`. - - ### Příkazy - - `grep "vzor" soubor` - hledá vzor v souboru - - `grep "vzor" soubor > výstup` - uloží nalezené řádky do souboru - - ### Úkol - 1. Najděte řádek s `ACTIVE_PROFILE` - 2. Přesměrujte výsledek do `profile.txt` - - ### Odevzdání - Odevzdejte název vytvořeného souboru: - `shellgame submit <název-souboru>` - """ - hints = [ - "Příkaz `grep` hledá zadaný vzor v souborech.", - "Vyhledejte klíč 'ACTIVE_PROFILE' v souboru 'config.txt' a výstup přesměrujte pomocí `>`.", - "Spusťte `grep ACTIVE_PROFILE config.txt > profile.txt` a výsledek ověřte pomocí `cat profile.txt`.", - ] - start_directory = "grep" - fixture = WorkspaceFixture( - clean=("grep/profile.txt", "grep/config.txt"), - files=( - FileFixture( - "grep/config.txt", - "user=admin\nhost=localhost\nport=8080\nACTIVE_PROFILE=production\ndebug=true\n", - ), - ), - ) - completion = Completion( - answer=ExactAnswer("profile.txt"), - requirements=( - TextFileContent( - "grep/config.txt", - contains=("ACTIVE_PROFILE=production",), - error_message="Zdrojový soubor config.txt chybí. Obnovte level příkazem `shellgame reset`.", - missing_message="Zdrojový soubor config.txt chybí. Obnovte level příkazem `shellgame reset`.", - unreadable_message=( - "Zdrojový soubor config.txt nelze přečíst. Obnovte level příkazem `shellgame reset`." - ), - ), - TextFileContent( - "grep/profile.txt", - exact="ACTIVE_PROFILE=production", - strip=True, - error_message="Soubor neobsahuje přesně hledaný řádek.", - missing_message="Výstup profile.txt musí být soubor. Obnovte level příkazem `shellgame reset`.", - unreadable_message="Soubor profile.txt nelze přečíst. Obnovte level příkazem `shellgame reset`.", - ), - ), - ) - success_message = "Správně! `grep` vybere jen potřebné řádky a `>` je uloží pro další použití." - - -@section.level(2) -class RecursiveGrepFindFileLevel(Level): - title = "Rekurzivní hledání" - instructions = """ - ### Cíl - Pomocí `grep -r` zjistěte, který soubor v adresáři `project` obsahuje text `SECRET_KEY`. - - ### Příkazy - - `grep -r "vzor" adresář/` - rekurzivní hledání - - ### Úkol - Najděte soubor obsahující `SECRET_KEY` a odevzdejte jeho relativní cestu. - - ### Odevzdání - `shellgame submit <cesta>` - """ - hints = [ - "Přepínač `-r` hledá rekurzivně ve všech podadresářích.", - 'Zkuste `grep -r "SECRET_KEY" project/`.', - "grep -r vypíše cestu před dvojtečkou. Odevzdejte právě tu cestu.", - ] - start_directory = "recursive" - fixture = WorkspaceFixture( - files=( - FileFixture("recursive/project/README.md", "# Project\n"), - FileFixture("recursive/project/src/main.py", "print('Hello')\n"), - FileFixture( - "recursive/project/config/settings.py", - "SECRET_KEY = 'xyz'\nDEBUG = True\n", - ), - ) - ) - completion = Completion( - answer=SuffixAnswer( - "config/settings.py", - error_message=( - "To není správná odpověď. `grep -r` vypisuje před dvojtečkou celou relativní cestu — " - "odevzdejte ji i s adresáři, ne jen samotný název souboru. Úvodní `./` nevadí." - ), - ) - ) - success_message = "Správně! S přepínačem -r prochází grep celý strom adresářů a u každého nálezu uvede cestu." - - -@section.level(3) -class CaseInsensitiveWarningCountLevel(Level): - title = "Hledání bez ohledu na velikost písmen" - instructions = """ - ### Cíl - Najděte všechny řádky obsahující slovo `warning` bez ohledu na velikost písmen a spočítejte je. - - ### Příkazy - - `grep -i "vzor" soubor` - ignoruje velikost písmen - - `grep -i "vzor" soubor | wc -l` - spočítá nalezené řádky - - ### Úkol - Spočítejte, kolik řádků v `messages.log` odpovídá `warning` (case-insensitive). - - ### Odevzdání - `shellgame submit <číslo>` - """ - hints = [ - "Bez `-i` najdete jen některé varianty. S `-i` najdete `warning`, `WARNING`, `Warning`…", - 'Zkuste `grep -i "warning" messages.log` a spočítejte vypsané řádky.', - "Počítejte každý řádek, který grep -i vypíše — ne jen jednu velikost písmen.", - ] - start_directory = "case" - fixture = WorkspaceFixture( - files=( - FileFixture( - "case/messages.log", - """2024-01-01 10:00:00 INFO: Server started -2024-01-01 10:05:00 WARNING: Memory usage high -2024-01-01 10:10:00 ERROR: Connection lost -2024-01-01 10:15:00 warning: Disk space low -2024-01-01 10:20:00 INFO: User logged in -2024-01-01 10:25:00 Warning: CPU temperature elevated -2024-01-01 10:30:00 DEBUG: Cache cleared -2024-01-01 10:35:00 WARNING: Network latency detected -2024-01-01 10:40:00 INFO: Backup completed -""", - ), - ) - ) - completion = Completion( - answer=IntegerAnswer( - 4, - mistakes={ - 2: "Našli jste jen 'WARNING'. Použijte -i pro nalezení všech variant (warning, Warning...).", - 1: "Našli jste jen jednu variantu. Přepínač -i ignoruje velikost písmen.", - }, - error_message="Počet není správně. Zkontrolujte, že hledáte bez ohledu na velikost písmen.", - invalid_message="Odpověď musí být číslo.", - ) - ) - success_message = "Správně! Přepínač -i je nepostradatelný pro robustní hledání." - - -@section.level(4) -class FindLostFilePathLevel(Level): - title = "Hledání souborů (find)" - instructions = """ - ### Cíl - Pomocí `find` najděte soubor `lost_file.txt` někde uvnitř `messy_dir`. - - ### Příkazy - - `find adresář -type f -name "název"` - hledá pouze soubory podle názvu - - ### Úkol - Najděte `lost_file.txt` a odevzdejte celou relativní cestu. - - ### Odevzdání - `shellgame submit <cesta>` - """ - hints = [ - "Příkaz `find` prohledá všechny podadresáře automaticky.", - 'Pro hledání souborů použijte: `find kde_hledat -type f -name "co_hledat"`.', - 'Použijte: `find messy_dir -type f -name "lost_file.txt"`.', - ] - start_directory = "find" - fixture = WorkspaceFixture( - files=( - FileFixture("find/messy_dir/a/b/c/d/lost_file.txt"), - FileFixture("find/messy_dir/other.txt"), - FileFixture("find/messy_dir/a/junk.txt"), - ) - ) - completion = Completion( - answer=SuffixAnswer( - "messy_dir/a/b/c/d/lost_file.txt", - error_message=( - "To není správná cesta. Odevzdejte ji přesně tak, jak ji vypsal `find` — " - "včetně všech adresářů, ne jen název souboru. Úvodní `./` nevadí." - ), - ) - ) - success_message = "Správně! `find` hledá podle vlastností souboru, takže si poradí i s neznámým umístěním." - - -@section.level(5) -class FindPythonFilesToListLevel(Level): - solution = Solution( - steps=(RunShell('find src_code -type f -name "*.py" | sort > python_files.txt'),), - answer="python_files.txt", - ) - title = "Hledání podle přípony" - instructions = """ - ### Cíl - Najděte všechny běžné `.py` soubory v `src_code` a uložte jejich cesty do `python_files.txt`. - Výstup seřaďte, aby měl vždy stejné pořadí. - - **Důležité:** Vzor musí být v uvozovkách: `"*.py"`. - Bez uvozovek by se ho nejprve pokusil rozbalit shell. S uvozovkami dostane vzor doslova `find`. - - ### Stavební prvky - - `find adresář -type f -name "*.py"` - hledá pouze soubory s příponou `.py` - - `sort` - seřadí řádky - - `>` - uloží výsledek - - ### Úkol - Vytvořte `python_files.txt` obsahující přesně seřazené cesty nalezených souborů. - - ### Odevzdání - `shellgame submit <název-souboru>` - """ - hints = [ - "Omezte `find` na běžné soubory pomocí `-type f`; adresář s příponou `.py` se počítat nemá.", - "Vzor dejte do uvozovek a výstup pošlete přes `sort` před uložením pomocí `>`.", - 'Použijte: `find src_code -type f -name "*.py" | sort > python_files.txt`.', - ] - start_directory = "extension" - fixture = WorkspaceFixture( - directories=("extension/src_code/archive.py",), - files=( - FileFixture("extension/src_code/main.py"), - FileFixture("extension/src_code/utils.py"), - FileFixture("extension/src_code/README.txt"), - FileFixture("extension/src_code/data.csv"), - ), - clean=("extension/python_files.txt",), - ) - completion = Completion( - answer=ExactAnswer("python_files.txt"), - requirements=( - TextFileContent( - "extension/python_files.txt", - exact="src_code/main.py\nsrc_code/utils.py", - strip=True, - error_message=( - "Obsah souboru nesedí. Bez `-type f` se do výpisu dostane i adresář s příponou `.py`; " - "bez uvozovek kolem vzoru ho rozbalí shell ještě před spuštěním `find`. " - "Nezapomeňte také na `sort`." - ), - missing_message=( - "Soubor zatím neexistuje. Výstup příkazu uložte pomocí `>` do souboru zadaného v úkolu." - ), - ), - ), - ) - success_message = "Správně! Spojení `find`, `sort` a `>` dává výsledek, který vyjde pokaždé stejně." - - -@section.level(6) -class FinalChallengeLevel(Level): - solution = Solution( - steps=( - RunShell('find . -type f -name "*.sh"'), - RunShell('grep -r "SECRET" .'), - RunShell("cp hidden/secret.sh found/secret.txt"), - RunShell('find . -type f -name "*.sh" | wc -l'), - ), - answer="4,NINJA2024", - ) - title = "Finální výzva" - instructions = """ - ### 🏆 Finální výzva: Terminálový ninja - - Gratulujeme! Dostali jste se na konec ShellGame. - Tato výzva kombinuje vyhledávání, filtrování, kopírování a počítání z posledních sekcí. - - ### Úkol - V `level-11/final`: - - 1. Najděte **všechny soubory** s příponou `.sh` rekurzivně pomocí `find`. - 2. Jeden z nich obsahuje tajný kód — najděte ho pomocí `grep`. - 3. Obsah skriptu s kódem zkopírujte do `found/secret.txt`. - 4. Počet `.sh` souborů zjistěte až po kopírování; díky příponě `.txt` zůstane stabilní. - - ### Struktura odpovědi - `<počet_skriptů>,<tajný_kód>` - - ### Odevzdání - `shellgame submit <počet>,<kód>` - """ - hints = [ - "Pro hledání skriptů spojte `find` s podmínkami `-type f` a `-name`; obsah pak prohledejte pomocí `grep`.", - 'Skripty vypíše `find . -type f -name "*.sh"`. V nalezených souborech hledejte řádek se slovem `SECRET`.', - 'Kopii vytvořte jako `found/secret.txt` a počet zjistěte přes `find . -type f -name "*.sh" | wc -l`. ', - "Odevzdejte počet a samotný kód.", - ] - start_directory = "final" - fixture = WorkspaceFixture( - directories=("final/found",), - files=( - FileFixture("final/run.sh", "#!/bin/bash\necho 'Running...'\n"), - FileFixture("final/scripts/build.sh", "#!/bin/bash\nmake all\n"), - FileFixture("final/scripts/test.sh", "#!/bin/bash\npytest\n"), - FileFixture( - "final/hidden/secret.sh", - "#!/bin/bash\n# SECRET_CODE=NINJA2024\necho 'You found me!'\n", - ), - FileFixture("final/readme.txt", "Look for shell scripts!\n"), - FileFixture("final/data.csv", "col1,col2\n"), - ), - clean=("final",), - ) - completion = Completion( - answer=TupleAnswer( - ( - IntegerAnswer( - 4, - error_message=("Počet .sh souborů není správně. Použijte 'find . -type f -name \"*.sh\"'."), - invalid_message="První hodnota musí být číslo.", - ), - ExactAnswer( - "NINJA2024", - case_sensitive=False, - error_message=("Tajný kód není správně. Hledejte 'SECRET' v obsahu skriptů pomocí 'grep'."), - ), - ), - format_message="Formát: počet,kód (např. 5,SECRET123)", - ), - requirements=( - PathsMatch( - "final/hidden/secret.sh", - "final/found/secret.txt", - error_message="Do složky found/ zkopírujte skript s tajným kódem.", - destination_error="Do složky found/ zkopírujte skript s tajným kódem.", - ), - ), - ) - success_message = """ -GRATULUJEME! - -Úspěšně jste dokončili ShellGame! - -Nyní ovládáte základy práce s terminálem: -✓ Navigace v souborovém systému -✓ Práce se soubory a adresáři -✓ Oprávnění a typy souborů -✓ Přesměrování a streamy -✓ Wildcards a vyhledávání - -Jste připraveni na další dobrodružství v Linuxu! -""".strip() diff --git a/src/shellgame/levels/sections/section2.py b/src/shellgame/levels/sections/section2.py deleted file mode 100644 index 1e8270e..0000000 --- a/src/shellgame/levels/sections/section2.py +++ /dev/null @@ -1,386 +0,0 @@ -"""File interaction and advanced navigation levels (Section 2).""" - -from __future__ import annotations - -from shellgame.levels.base import Level -from shellgame.levels.cdpolicy import ( - CdEvidence, - CdPolicy, - RequireExactCommand, - RequireSourceDirectory, - WithTarget, -) -from shellgame.levels.collector import Section -from shellgame.levels.completion import ( - AtDirectory, - ChoiceAnswer, - Completion, - ExactAnswer, - FileExists, -) -from shellgame.levels.fixture import FileFixture, WorkspaceFixture -from shellgame.levels.solution import Chdir, PerformCd, RunShell, Solution - -section = Section(2, root="level-2") - - -@section.level(0) -class SectionIntroLevel(Level): - is_intro = True - title = "Práce se soubory" - instructions_file = "section2_intro.md" - hints = ["Přečtěte si úvod a pokračujte stisknutím Enter."] - # Keep consistent behavior with existing tests/UX: do not force a start dir for intro. - start_directory = None - success_message = "Jdeme na to!" - - -@section.level(1) -class SiblingNavigationLevel(Level): - solution = Solution(steps=(Chdir("finish"),), answer="finish") - title = "Navigace mezi sourozenci" - instructions = """ - ### Cíl - Přejděte z aktuálního podadresáře do sousedního (sourozeneckého) adresáře. - - ### Struktura adresářů - ``` - level-2/ - ├── start/ ← Zde se nacházíte (start) - └── finish/ ← Váš cíl - ``` - - ### Navigace mezi sourozenci - Adresáře `start` a `finish` leží vedle sebe ve stejném rodičovském adresáři (`level-2`). - Protože `finish` neleží uvnitř `start`, nelze použít přímý příkaz `cd finish`. - Musíte se nejprve vrátit k rodiči (`..`) a odtud vstoupit do cíle: - - Ve dvou krocích: nejprve o úroveň výše (`cd ..`) a potom do cíle. - - Nebo v jednom kroku spojenou relativní cestou: `cd ../<cílový_adresář>`. - - ### Úkol - 1. Začínáte v adresáři `level-2/start` - 2. Přejděte do sousedního adresáře `finish` - 3. V cíli ověřte polohu příkazem `pwd` a odešlete řešení - - ### Odevzdání - V cílovém adresáři spusťte: - `shellgame submit` - """ - hints = [ - "Do sourozeneckého adresáře se dostanete přes společného rodiče ('..').", - "Zkuste nejprve vystoupat o úroveň výše nebo použít spojenou relativní cestu začínající '../'.", - "Po přesunu ověřte polohu příkazem 'pwd' a spusťte 'shellgame submit'.", - ] - start_directory = "start" - fixture = WorkspaceFixture(directories=("start", "finish")) - completion = Completion( - answer=ExactAnswer("finish"), - requirements=(AtDirectory("finish"),), - allow_empty=True, - ) - success_message = "Správně! K sourozenci se chodí přes společného rodiče — nahoru a hned dolů jiným směrem." - - -@section.level(2) -class PreviousDirectoryToggleLevel(Level): - solution = Solution( - steps=(Chdir("location-B"), PerformCd("-", move_to="location-A")), - ) - title = "Rychlý návrat" - instructions = """ - ### Cíl - Naučte se rychle přepínat mezi dvěma adresáři. - - ### Příkazy k naučení - - `cd -` (návrat do předchozího adresáře) - - ### Úkol - 1. Začínáte v `level-2/location-A`. - 2. Přejděte do `level-2/location-B` (použijte `cd ../location-B`). - 3. Použijte příkaz `cd -` pro okamžitý návrat zpět do `location-A`. - 4. Po návratu spusťte pouze `shellgame submit`. - """ - hints = [ - "Příkaz 'cd -' vás vrátí do předchozího pracovního adresáře (jako tlačítko Zpět).", - "Nejprve přejděte do 'location-B' ('cd ../location-B') a odtud zadejte 'cd -'.", - "Po návratu ověřte polohu příkazem 'pwd' a spusťte jen 'shellgame submit'.", - ] - start_directory = "location-A" - fixture = WorkspaceFixture(directories=("location-A", "location-B")) - completion = Completion( - requirements=( - AtDirectory("location-A"), - CdEvidence( - "Nejdřív přejděte do `location-B` a vraťte se příkazem `cd -`.", - ), - ), - ) - - cd_policy = CdPolicy( - scope=(WithTarget("-"),), - rules=(RequireSourceDirectory("location-B", "Příkaz 'cd -' použijte až z adresáře location-B."),), - ) - success_message = "Správně! Shell si pamatuje předchozí adresář, takže `cd -` přepíná mezi dvěma místy bez cesty." - - -@section.level(3) -class DeepRelativeNavigationLevel(Level): - solution = Solution(steps=(PerformCd("../../other/target"),)) - title = "Hluboká navigace" - instructions = """ - ### Cíl - Navigace složitější strukturou pomocí relativních cest. - - ### Úkol - Nacházíte se hluboko ve struktuře adresářů. - Vaším cílem je přejít do jiné větve stromu pomocí jediného příkazu `cd`. - - Start: `.../level-2/deep/structure/start` - Cíl: `.../level-2/deep/other/target` - - Musíte jít o dvě úrovně výše a pak dolů do `other/target`. - - V cíli spusťte pouze `shellgame submit`. - Potřebujete pomoc? Napište: `shellgame hint` - """ - hints = [ - "Pro přechod do jiné větve stromu musíte nejprve vystoupat nahoru přes '..' a pak sestoupit dolů.", - "Ze 'start' vystoupejte o dvě úrovně ('../..') a zadejte 'cd ../../other/target'.", - "Po přesunu ověřte polohu příkazem 'pwd' a spusťte jen 'shellgame submit'.", - ] - start_directory = "deep/structure/start" - fixture = WorkspaceFixture( - directories=("deep/structure/start", "deep/other/target"), - ) - completion = Completion( - requirements=( - AtDirectory("deep/other/target"), - CdEvidence( - "Použijte ze startu jeden relativní příkaz `cd ../../other/target`.", - ), - ), - ) - - cd_policy = CdPolicy( - rules=( - RequireSourceDirectory("deep/structure/start", "Použijte ze startu jeden příkaz 'cd ../../other/target'."), - RequireExactCommand("../../other/target", "Použijte ze startu jeden příkaz 'cd ../../other/target'."), - ) - ) - success_message = "Správně! Jedna relativní cesta umí obsahovat výstup i sestup — nejdřív `..`, potom jména větve." - - -@section.level(4) -class ReadFirstWordLevel(Level): - title = "Čtení souboru" - instructions = """ - ### Cíl - Přečtěte si obsah souboru. - - ### Příkazy k naučení - - `cat <soubor>` (vypíše obsah souboru do terminálu) - - ### ⚠️ Bezpečnostní pojistka: Ctrl+C - Kdybyste omylem spustili `cat` bez názvu souboru, příkaz neví, co číst, - a začne čekat na vstup z klávesnice (terminál se zdánlivě „zasekne“). - Kdykoliv se vám to stane, stiskněte **Ctrl+C** — to běžící příkaz okamžitě přeruší. - - ### Úkol - 1. V aktuálním adresáři je soubor `message.txt`. - 2. Přečtěte si jeho obsah pomocí `cat message.txt`. - 3. Odevzdejte **PRVNÍ SLOVO**, které v souboru najdete. - - Odevzdejte pomocí: `shellgame submit <slovo>` - Potřebujete pomoc? Napište: `shellgame hint` - """ - hints = [ - "Příkaz 'cat' vypíše obsah textového souboru na obrazovku.", - "Spusťte 'cat message.txt' pro zobrazení obsahu souboru.", - "Z výstupu vezměte jen první slovo (před první mezerou) a zadejte: 'shellgame submit <slovo>'.", - ] - start_directory = "" - fixture = WorkspaceFixture(files=(FileFixture("message.txt", "Secret is the key.\n"),)) - completion = Completion( - answer=ExactAnswer( - "secret", - case_sensitive=False, - required_message="Musíte zadat první slovo ze zprávy: shellgame submit <slovo>", - ) - ) - success_message = "Správně! `cat` vysype celý obsah souboru do terminálu — ideální na krátké textové soubory." - - -@section.level(5) -class ChainedClueTraversalLevel(Level): - title = "Sledování stop" - instructions = """ - ### Cíl - Sledujte stopy v souborech k nalezení hesla. - - ### Úkol - 1. Přečtěte si soubor `start.txt` v aktuálním adresáři. - 2. Postupujte podle instrukcí v souboru. - 3. Najděte finální heslo a odevzdejte ho. - - Odevzdejte pomocí: `shellgame submit <heslo>` - Potřebujete pomoc? Napište: `shellgame hint` - """ - hints = [ - "Začněte čtením start.txt - co vám soubor říká?", - "Instrukce vás posílají někam dál. Jaký příkaz použijete pro přesun do adresáře?", - "Přečtěte start.txt, přejděte do adresáře 'next', přečtěte clue.txt.", - ] - start_directory = "" - fixture = WorkspaceFixture( - files=( - FileFixture("start.txt", "Jděte do adresáře 'next' a přečtěte si clue.txt\n"), - FileFixture("next/clue.txt", "Heslo je 'sunshine'\n"), - ) - ) - completion = Completion( - answer=ExactAnswer( - "sunshine", - case_sensitive=False, - required_message="Musíte zadat heslo: shellgame submit <heslo>", - ) - ) - success_message = "Správně! Střídat `ls`, `cd` a `cat` stačí k prozkoumání libovolné neznámé struktury." - - -@section.level(6) -class CreateFileWithTouchLevel(Level): - solution = Solution(steps=(RunShell("touch my_file.txt"),), answer=None) - title = "Vytvoření souboru" - instructions = """ - ### Cíl - Vytvořte nový prázdný soubor. - - ### Příkazy k naučení - - `touch <název>` (vytvoří prázdný soubor nebo aktualizuje časové značky existujícího souboru) - - ### Úkol - 1. Vytvořte soubor s názvem `my_file.txt` v aktuálním adresáři. - 2. Ověřte jeho existenci pomocí `ls`. - - Odevzdejte pomocí: `shellgame submit` - Potřebujete pomoc? Napište: `shellgame hint` - """ - hints = [ - "Příkaz 'touch' vytvoří prázdný soubor se zadaným názvem.", - "Spusťte 'touch my_file.txt' v aktuálním adresáři.", - "Ověřte vytvoření souboru příkazem 'ls' a odešlete: 'shellgame submit'.", - ] - start_directory = "" - fixture = WorkspaceFixture(clean=("my_file.txt",)) - completion = Completion(requirements=(FileExists("my_file.txt"),)) - success_message = "Správně! `touch` založí prázdný soubor; u existujícího jen posune časové značky." - - -@section.level(7) -class HelpDiscoveryLevel(Level): - solution = Solution(answer="human-readable") - title = "Jak najít pomoc" - instructions = """ - ### Cíl - Naučte se vyhledávat v nápovědě k příkazům. - - ### Dva způsoby, jak získat pomoc - Nikdo si nepamatuje všechny přepínače všech příkazů: - 1. `<příkaz> --help` → stručný přehled přepínačů přímo v terminálu - 2. `man <příkaz>` → podrobný manuál (stránkovaný; ukončíte ho klávesou `q`) - - ### 💡 Hledání v manuálu (`man`) - V manuálu můžete vyhledávat: - - Stiskněte klávesu `/`, napište hledaný text (např. `-h`) a stiskněte **Enter**. - - Klávesou `n` přejdete na další výskyt. - - Klávesou `q` manuál ukončíte. - - ### Úkol - 1. Spusťte `ls --help` nebo otevřete manuál `man ls`. - 2. Vyhledejte přepínač `-h`. - 3. Krátké přepínače mívají svůj dlouhý ekvivalent začínající na `--` (např. `-a` má `--all`). - Jaký dlouhý název má přepínač `-h`? - 4. Odevzdejte tento dlouhý název (např. `human-readable` nebo `--human-readable`). - - ### Odevzdání - `shellgame submit <název>` - """ - hints = [ - "Spusťte 'ls --help' nebo 'man ls' a vyhledejte řádek s přepínačem '-h'.", - "V nápovědě uvidíte zápis ve tvaru '-h, --název'. Hledejte slovo za dvěma pomlčkami.", - "Přepínač '-h' je zkratka pro 'human-readable'. Odevzdejte: shellgame submit human-readable", - ] - start_directory = "" - completion = Completion( - answer=ChoiceAnswer( - ( - "human-readable", - "--human-readable", - "human readable", - "human", - "čitelné", - "citelne", - "čitelné formátování", - ), - case_sensitive=False, - error_message=( - "Odpověď není správně. V nápovědě vyhledejte řádek s přepínačem '-h' " - "a najděte jeho dlouhý název (--...)." - ), - required_message="Musíte zadat odpověď: shellgame submit <název>", - ) - ) - success_message = "Správně! Teď víte, jak najít pomoc. Příkaz --help a man jsou vaši nejlepší přátelé!" - - -@section.level(8) -class SectionChallengeLevel(Level): - solution = Solution( - steps=(Chdir("challenge/room1"), Chdir("challenge/room2")), - answer="navigator", - ) - title = "Integrační výzva" - instructions = """ - ### Výzva: Propojte navigaci a čtení souborů - - Závěrečný úkol sekce: použijete v něm několik dovedností najednou. - - ### Úkol - 1. Začínáte v `level-2`. Přejděte do `challenge/room1`. - 2. Přečtěte soubor `hint.txt`. - 3. Podle stopy přejděte do `room2`. - 4. Přečtěte `password.txt` a odevzdejte nalezené heslo. - - ### Odevzdání - `shellgame submit <hodnota>` - """ - hints = [ - "Ze startu přejděte do 'challenge/room1' a přečtěte 'hint.txt'.", - "Do sousedního 'room2' se dostanete například příkazem 'cd ../room2'.", - "V 'room2' přečtěte soubor 'password.txt' pomocí 'cat'.", - ] - start_directory = "" - fixture = WorkspaceFixture( - files=( - FileFixture( - "challenge/room1/hint.txt", - "Heslo je v sousedním adresáři room2.\n", - ), - FileFixture("challenge/room2/password.txt", "navigator\n"), - FileFixture("challenge/room2/decoy.txt", "Toto není heslo.\n"), - ) - ) - completion = Completion( - answer=ExactAnswer( - "navigator", - case_sensitive=False, - mistakes={ - "toto není heslo": "To je obsah decoy.txt, ne password.txt. Přečtěte správný soubor.", - "toto neni heslo": "To je obsah decoy.txt, ne password.txt. Přečtěte správný soubor.", - }, - error_message="Heslo není správné. Hledejte password.txt v room2.", - required_message="Musíte zadat heslo: shellgame submit <hodnota>", - ), - requirements=(AtDirectory("challenge/room2"),), - ) - success_message = "Výborně! Dokončili jste Sekci 2 a umíte propojit navigaci se čtením souborů." diff --git a/src/shellgame/levels/sections/section3.py b/src/shellgame/levels/sections/section3.py deleted file mode 100644 index 96d7101..0000000 --- a/src/shellgame/levels/sections/section3.py +++ /dev/null @@ -1,230 +0,0 @@ -"""Section 3: Hidden Files.""" - -from __future__ import annotations - -from shellgame.levels.base import Level -from shellgame.levels.collector import Section -from shellgame.levels.completion import ( - AtDirectory, - Completion, - ExactAnswer, - IntegerAnswer, - TupleAnswer, -) -from shellgame.levels.fixture import FileFixture, WorkspaceFixture -from shellgame.levels.solution import Chdir, Solution - -section = Section(3, root="level-3") - - -@section.level(0) -class SectionIntroLevel(Level): - is_intro = True - title = "Sekce 3: Skryté soubory" - instructions_file = "section3_intro.md" - hints = ["Přečtěte si úvod a pokračujte stisknutím Enter."] - success_message = "Jdeme na to!" - - -@section.level(1) -class HiddenDirCountLevel(Level): - title = "Počítání skrytých adresářů" - instructions = """ - ### Cíl - Najděte a spočítejte skryté adresáře. - - ### Příkazy k naučení - - `ls -a` zobrazí všechny položky včetně skrytých - - `ls -la` navíc zobrazí podrobnosti a typ položky - - ### Úkol - Nacházíte se v adresáři `level-3/hub`. - 1. Použijte `ls -la` pro zobrazení všech položek i jejich typů. - 2. Spočítejte **skryté adresáře**: jejich název začíná tečkou a řádek znakem `d`. - 3. **Důležité:** Do počtu NEZAHRNUJTE speciální adresáře `.` (aktuální) a `..` (nadřazený). - - Odevzdejte počet nalezených skrytých adresářů (číslo). - - Odevzdejte pomocí: `shellgame submit <hodnota>` - Potřebujete pomoc? Napište: `shellgame hint` - """ - hints = [ - "Použijte 'ls -la': -a zobrazí skryté položky a -l přidá podrobnosti.", - "Adresář poznáte podle znaku 'd' na začátku řádku; běžný soubor začíná '-'.", - "Počítejte skryté názvy v řádcích začínajících 'd', ale vynechte položky '.' a '..'.", - ] - start_directory = "hub" - fixture = WorkspaceFixture( - clean=("hub",), - directories=("hub/.beta", "hub/.gamma", "hub/visible_dir"), - files=(FileFixture("hub/.config"), FileFixture("hub/visible_file.txt")), - ) - completion = Completion( - answer=IntegerAnswer( - 2, - mistakes={ - 3: "Nejspíš počítáte i skrytý soubor '.config'. Jeho řádek v 'ls -la' nezačíná 'd'.", - 4: "Nejspíš počítáte i položky '.' a '..'. Jsou to speciální odkazy, ne hledané adresáře.", - 5: "Nejspíš počítáte '.', '..' i skrytý soubor. Sledujte první znak řádku a přesný název.", - }, - ) - ) - success_message = "Správně! Skryté položky zobrazí `ls -a` a typ položky poznáte podle prvního znaku řádku `ls -l`." - - -@section.level(2) -class HiddenFileReadLevel(Level): - title = "Čtení skrytého souboru" - instructions = """ - ### Cíl - Přečtěte obsah skrytého souboru. - - ### Úkol - V aktuálním adresáři je skrytý soubor `.secret_config`. - 1. Ověřte jeho existenci pomocí `ls -a`. - 2. Přečtěte jeho obsah pomocí `cat`. - 3. Odevzdejte obsah souboru. - - Odevzdejte pomocí: `shellgame submit <obsah>` - Potřebujete pomoc? Napište: `shellgame hint` - """ - hints = [ - "Skryté soubory začínají tečkou. Jak je zobrazíte pomocí ls?", - "Příkaz 'cat' funguje i na skryté soubory - stačí zadat správný název včetně tečky.", - "Zkuste: cat .secret_config", - ] - start_directory = "" - fixture = WorkspaceFixture(files=(FileFixture(".secret_config", "mode=stealth\n"),)) - completion = Completion(answer=ExactAnswer("mode=stealth")) - success_message = "Správně! Tečka na začátku názvu soubor jen skryje ve výpisu, práci s ním nijak neomezuje." - - -@section.level(3) -class HiddenVaultKeyLevel(Level): - solution = Solution(steps=(Chdir("hub/.vault"),), answer="platinum") - title = "Uvnitř skrytého adresáře" - instructions = """ - ### Cíl - Vstupte do skrytého adresáře. - - ### Úkol - 1. Najděte skrytý adresář `.vault`. - 2. Vstupte do něj (`cd .vault`). - 3. Uvnitř najděte soubor `key.txt` a přečtěte ho. - 4. Odevzdejte nalezený klíč. - - Odevzdejte pomocí: `shellgame submit <klíč>` - Potřebujete pomoc? Napište: `shellgame hint` - """ - hints = [ - "Jak byste vstoupili do běžného adresáře? Stejně to funguje i se skrytými.", - "Skrytý adresář .vault - jak se do něj dostanete pomocí cd?", - "Jděte do .vault pomocí 'cd .vault', pak přečtěte key.txt.", - ] - start_directory = "hub" - fixture = WorkspaceFixture(files=(FileFixture("hub/.vault/key.txt", "platinum\n"),)) - completion = Completion( - answer=ExactAnswer("platinum", case_sensitive=False), - requirements=(AtDirectory("hub/.vault"),), - ) - success_message = "Správně! Do skrytého adresáře se vstupuje příkazem `cd` úplně stejně jako do viditelného." - - -@section.level(4) -class HiddenBackupSuffixLevel(Level): - title = "Skrytá záloha" - instructions = """ - ### Cíl - Identifikujte skrytý soubor podle přípony. - - ### Úkol - V adresáři `level-3/backup` je několik skrytých souborů. - Najděte ten, který má příponu `.bak` (záloha). - Odevzdejte jeho celý název. - - Odevzdejte pomocí: `shellgame submit <název-souboru>` - Potřebujete pomoc? Napište: `shellgame hint` - """ - hints = [ - "Použijte 'ls -a' v adresáři backup.", - "Hledejte soubor začínající tečkou a končící .bak.", - "Odevzdejte celý název včetně tečky na začátku.", - ] - start_directory = "backup" - fixture = WorkspaceFixture( - files=( - FileFixture("backup/.config"), - FileFixture("backup/.data.bak"), - FileFixture("backup/normal.txt"), - ) - ) - completion = Completion(answer=ExactAnswer(".data.bak")) - success_message = "Správně! Úvodní tečka i přípona jsou součástí názvu, takže se soubor odevzdává celým jménem." - - -@section.level(5) -class HiddenFilesSummaryChallengeLevel(Level): - solution = Solution(answer="2,hidden_master") - title = "Souhrn Sekce 3" - instructions = """ - ### Výzva: Mistři skrytých souborů - - Ukažte, že ovládáte práci se skrytými soubory! - - ### Úkol - V adresáři `level-3/final_test` jsou normální i skryté položky. - - 1. Pomocí `ls -la` spočítejte **skryté adresáře** (název začíná tečkou, řádek znakem `d`; bez `.` a `..`) - 2. Najděte skrytý soubor `.secret_code` - 3. Přečtěte jeho obsah - 4. Odevzdejte dvojici ve schématu `POČET,KÓD` - - ### Shrnutí příkazů Sekce 3 - ``` - ls -a → Zobrazí vše včetně skrytých - ls -la → Přidá podrobnosti; adresář má na začátku řádku d - cat .soubor → Přečíst skrytý soubor - cd .adresar → Vstoupit do skrytého adresáře - ``` - - ### Odevzdání - `shellgame submit <hodnota>` - """ - hints = [ - "Skryté položky začínají tečkou. Použijte 'ls -la' pro zobrazení všech položek i jejich typů.", - "Adresáře poznáte podle 'd' na začátku řádku; běžné soubory začínají '-'.", - "Položky '.' a '..' nepočítejte. Obsah souboru zobrazíte pomocí 'cat .secret_code'.", - ] - start_directory = "final_test" - fixture = WorkspaceFixture( - clean=("final_test",), - directories=("final_test/.hidden_dir1", "final_test/.hidden_dir2", "final_test/visible_dir"), - files=( - FileFixture("final_test/.secret_code", "hidden_master\n"), - FileFixture("final_test/.config", "not this one\n"), - FileFixture("final_test/.notes", "Poznámky nejsou adresář.\n"), - FileFixture("final_test/readme.txt", "Look for hidden items!\n"), - ), - ) - completion = Completion( - answer=TupleAnswer( - ( - IntegerAnswer( - 2, - mistakes={4: "Možná počítáte i ./ a ../. Ty vynechte; počítejte jen skryté adresáře."}, - error_message=( - "Počet skrytých adresářů není správně. V 'ls -la' sledujte první znak řádku a název." - ), - invalid_message="První část musí být číslo (počet skrytých adresářů).", - ), - ExactAnswer( - "hidden_master", - case_sensitive=False, - mistakes={"not this one": "To je obsah .config, ne .secret_code."}, - error_message="Kód není správný. Přečtěte .secret_code.", - ), - ), - format_message="Formát odpovědi je: POČET,KÓD", - ) - ) - success_message = "Výborně! Dokončili jste Sekci 3. Skryté soubory před vámi nic neskryjí!" diff --git a/src/shellgame/levels/sections/section4.py b/src/shellgame/levels/sections/section4.py deleted file mode 100644 index 693cff4..0000000 --- a/src/shellgame/levels/sections/section4.py +++ /dev/null @@ -1,442 +0,0 @@ -"""Section 4: Creation and Cleanup (declarative style).""" - -from __future__ import annotations - -from shellgame.levels.base import Level -from shellgame.levels.collector import Section -from shellgame.levels.completion import ( - Completion, - DirectoryExists, - ExactAnswer, - FileExists, - TextFileContent, -) -from shellgame.levels.fixture import FileFixture, WorkspaceFixture -from shellgame.levels.solution import RunShell, Solution - -section = Section(4, root="level-4") - - -@section.level(0) -class SectionIntroLevel(Level): - is_intro = True - title = "Sekce 4: Vytváření a mazání" - instructions_file = "section4_intro.md" - hints = ["Přečtěte si úvod a pokračujte stisknutím Enter."] - start_directory = None - success_message = "Jdeme na to!" - - -@section.level(1) -class CreateFileLevel(Level): - solution = Solution(steps=(RunShell("touch novy_soubor.txt"),), answer=None) - title = "Vytvoření souboru" - instructions = """ - ### Cíl - Vytvořte nový prázdný soubor. - - ### Příkazy - - `touch <název_souboru>` (vytvoří prázdný soubor nebo aktualizuje časové značky) - - ### Úkol - Vytvořte prázdný soubor s názvem `novy_soubor.txt` v aktuálním adresáři. - - ### Odevzdání - Až bude soubor existovat a bude zcela prázdný, spusťte: - `shellgame submit` - """ - hints = [ - "Příkaz 'touch' vytvoří prázdný soubor. Jaký název má mít?", - "Syntaxe je jednoduchá: touch název_souboru", - "Použijte 'touch novy_soubor.txt', ověřte výsledek pomocí 'ls -l' a spusťte 'shellgame submit'.", - ] - start_directory = "creation" - fixture = WorkspaceFixture( - directories=("creation",), - clean=("creation/novy_soubor.txt",), - ) - completion = Completion( - requirements=( - FileExists("creation/novy_soubor.txt"), - TextFileContent( - "creation/novy_soubor.txt", - exact="", - error_message="Soubor novy_soubor.txt existuje, ale není prázdný.", - ), - ), - ) - success_message = "Správně! Soubor může vzniknout úplně prázdný a obsah do něj doplníte až později." - - -@section.level(2) -class CreateDirectoryLevel(Level): - solution = Solution(steps=(RunShell("mkdir data"),), answer="data") - title = "Vytvoření adresáře" - instructions = """ - ### Cíl - Vytvořte nový adresář. - - ### Příkazy - - `mkdir <název_adresáře>` (make directory) - - ### Úkol - Vytvořte adresář s názvem `data` v aktuálním adresáři. - - ### Odevzdání - Odevzdejte název vytvořeného adresáře: - `shellgame submit <název>` - """ - hints = [ - "Příkaz 'mkdir' slouží k vytvoření nového adresáře.", - "Spusťte 'mkdir data' pro vytvoření adresáře data.", - "Ověřte pomocí 'ls -l' (řádek adresáře začíná 'd') a odevzdejte 'data'.", - ] - start_directory = "creation" - fixture = WorkspaceFixture( - directories=("creation",), - clean=("creation/data",), - ) - completion = Completion( - answer=ExactAnswer("data"), - requirements=( - DirectoryExists( - "creation/data", - error_message=( - "Požadovaný adresář zatím neexistuje. Ověřte pomocí 'ls -l', " - "že jste ho založili v aktuálním adresáři a bez překlepu v názvu." - ), - ), - ), - ) - success_message = "Správně! Nový adresář vznikne jediným příkazem a hned do něj můžete vstoupit." - - -@section.level(3) -class NestedDirectoryCreationLevel(Level): - solution = Solution(steps=(RunShell("mkdir -p projekt/src/tests"),), answer=None) - title = "Vytváření zanořených adresářů" - instructions = """ - ### Cíl - Vytvořte zanořenou strukturu adresářů jedním příkazem. - - ### Vytváření cest s přepínačem `-p` - Při vytváření celé cesty (např. `projekt/src/tests`) příkaz `mkdir` bez přepínače - selže, pokud nadřazené adresáře neexistují. - Přepínač `-p` (parents) vytvoří celou cestu včetně chybějících rodičovských adresářů. - - ### Příkazy - - `mkdir -p <cesta>` (vytvoří adresář včetně všech chybějících nadřazených složek) - - ### Úkol - Vytvořte strukturu adresářů `projekt/src/tests`. - - ### Odevzdání - Až bude celá struktura vytvořená, spusťte: - `shellgame submit` - """ - hints = [ - "Co se stane, když zkusíte 'mkdir projekt/src/tests' bez přepínače -p?", - "Přepínač -p (parents) vytvoří i všechny nadřazené adresáře, které chybí.", - "Použijte 'mkdir -p projekt/src/tests'.", - ] - start_directory = "nested" - fixture = WorkspaceFixture( - directories=("nested",), - clean=("nested/projekt",), - ) - completion = Completion( - requirements=( - DirectoryExists( - "nested/projekt/src/tests", - error_message=( - "Struktura projekt/src/tests zatím není celá. Zkontrolujte ji pomocí 'ls -R projekt': " - "bez přepínače -p vytvoří mkdir jen poslední článek cesty, a to jen když jeho rodič už existuje. " - "S -p vzniknou chybějící rodičovské adresáře zároveň s ním." - ), - ), - ) - ) - success_message = "Správně! Přepínač -p vytvoří celou cestu naráz, takže rodiče nemusíte zakládat po jednom." - - -@section.level(4) -class DeleteFileLevel(Level): - solution = Solution(steps=(RunShell("rm stary_log.txt"),), answer="stary_log.txt") - title = "Mazání souborů" - instructions = """ - ### Cíl - Smažte nepotřebný soubor. - - ### Příkazy - - `rm <soubor>` (remove — smaže zadaný soubor) - - ### ⚠️ Důležité varování - Příkaz `rm` nepřesouvá soubory do koše jako grafické prostředí — maže je okamžitě a nevratně. - - `rm -i <soubor>` se před smazáním zeptá na potvrzení. - - Před hromadným mazáním (např. `rm *.log`) si obsah zkontrolujte pomocí `ls *.log`. - - ### Úkol - Smažte soubor `stary_log.txt`, který se nachází v aktuálním adresáři. - - ### Odevzdání - Odevzdejte název smazaného souboru: - `shellgame submit <název>` - """ - hints = [ - "Příkaz rm permanentně maže soubory. Jaký soubor máte smazat?", - "Syntaxe je jednoduchá: rm název_souboru", - "Použijte 'rm stary_log.txt'.", - ] - start_directory = "cleanup" - fixture = WorkspaceFixture( - files=(FileFixture("cleanup/stary_log.txt", "old data"),), - ) - completion = Completion( - answer=ExactAnswer("stary_log.txt"), - requirements=( - FileExists( - "cleanup/stary_log.txt", - should_exist=False, - error_message=( - "Soubor stary_log.txt tu pořád je. Zkontrolujte pomocí 'ls', že mažete ve správném " - "adresáři a že jste název napsali bez překlepu." - ), - ), - ), - ) - success_message = "Správně! Mazání v shellu je okamžité a nevratné, proto se vyplatí nejdřív ověřit, co mažete." - - -@section.level(5) -class DeleteDirectoryLevel(Level): - solution = Solution(steps=(RunShell("rm -r temp"),), answer="temp") - title = "Mazání adresářů" - instructions = """ - ### Cíl - Smažte adresář i s jeho obsahem. - - ### Příkazy - - `rmdir <adresář>` (odstraní prázdný adresář; s obsahem selže) - - `rm -r <adresář>` (recursive — rekurzivně smaže adresář i s obsahem) - - ### Úkol - Smažte adresář `temp`, který obsahuje nějaké dočasné soubory. - - ### Odevzdání - Odevzdejte název smazaného adresáře: - `shellgame submit <název>` - """ - hints = [ - "Zkuste nejdřív 'rmdir temp'. Co se stane?", - "Pokud adresář není prázdný, rmdir selže. Jaký přepínač potřebujete pro rekurzivní mazání?", - "Použijte 'rm -r temp' pro smazání adresáře včetně obsahu.", - ] - start_directory = "cleanup" - fixture = WorkspaceFixture(files=(FileFixture("cleanup/temp/junk.txt", "junk"),)) - completion = Completion( - answer=ExactAnswer("temp"), - requirements=( - DirectoryExists( - "cleanup/temp", - should_exist=False, - error_message=( - "Zadaný adresář tu pořád je. Pokud rmdir skončil hláškou 'Directory not empty', " - "pracoval přesně podle očekávání: umí odstranit jen prázdný adresář a nesmí se dotknout obsahu. " - "Adresář s obsahem proto smažte až rekurzivní variantou příkazu rm." - ), - ), - ), - ) - success_message = "Správně! Prázdný adresář zvládne rmdir, na adresář s obsahem je potřeba rekurzivní mazání." - - -@section.level(6) -class ProjectScaffoldLevel(Level): - solution = Solution( - steps=(RunShell("mkdir -p web/css web/js && touch web/index.html web/css/style.css"),), - answer="web", - ) - title = "Příprava projektu" - instructions = """ - ### Cíl - Připravte kompletní kostru nového projektu. - - ### Příkazy k použití - - `mkdir -p <cesta>` pro adresáře - - `touch <cesta>` pro soubory - - ### Úkol - Vytvořte následující strukturu v adresáři `web`: - - `web/index.html` (soubor) - - `web/css/style.css` (soubor v podadresáři) - - `web/js` (prázdný adresář) - - ### Odevzdání - Odevzdejte název kořenového adresáře projektu: - `shellgame submit <název>` - """ - hints = [ - "Nejdřív si rozdělte úkol na adresáře a soubory. Všechny potřebné adresáře lze vytvořit jedním příkazem.", - "Pro adresáře použijte 'mkdir -p' a pro prázdné soubory 'touch'; oba příkazy přijímají více cest.", - "Spusťte 'mkdir -p web/css web/js' a potom 'touch web/index.html web/css/style.css'.", - ] - start_directory = "project" - fixture = WorkspaceFixture( - directories=("project",), - clean=("project/web",), - ) - completion = Completion( - answer=ExactAnswer("web"), - requirements=( - FileExists( - "project/web/index.html", - error_message="Chybí soubor web/index.html. Prázdný soubor založí 'touch'.", - ), - FileExists( - "project/web/css/style.css", - error_message=( - "Chybí soubor web/css/style.css. Nejdřív musí existovat podadresář css, " - "teprve potom do něj lze soubor založit." - ), - ), - DirectoryExists( - "project/web/js", - error_message="Chybí adresář web/js. Má zůstat prázdný, ale existovat musí.", - ), - ), - ) - success_message = "Správně! Kostru projektu připravíte předem: adresáře jedním příkazem, prázdné soubory druhým." - - -@section.level(7) -class CleanupMultipleFilesLevel(Level): - solution = Solution(steps=(RunShell("rm error.log temp.dat junk.tmp"),), answer=None) - title = "Úklid nepořádku" - instructions = """ - ### Cíl - Smažte více souborů najednou a zachovejte důležitá data. - - ### Příkazy - - `rm soubor1 soubor2 ...` (smaže více souborů najednou) - - ### Úkol - V adresáři `mess` smažte soubory `error.log`, `temp.dat` a `junk.tmp`. - Soubor `keep_me.txt` musí zůstat zachovaný. Hodnotí se výsledný stav, ne počet příkazů. - - ### Odevzdání - Až budou nepotřebné soubory pryč, spusťte: - `shellgame submit` - """ - hints = [ - "Příkaz 'rm' dokáže smazat více souborů najednou, stačí je uvést oddělené mezerami.", - "Spusťte 'rm error.log temp.dat junk.tmp' (nebo je smažte postupně po jednom).", - "Ověřte pomocí 'ls', že zůstal jen soubor keep_me.txt, a spusťte 'shellgame submit'.", - ] - start_directory = "mess" - fixture = WorkspaceFixture( - files=( - FileFixture("mess/error.log"), - FileFixture("mess/temp.dat"), - FileFixture("mess/junk.tmp"), - FileFixture("mess/keep_me.txt", "important"), - ) - ) - completion = Completion( - requirements=( - FileExists("mess/error.log", should_exist=False), - FileExists("mess/temp.dat", should_exist=False), - FileExists("mess/junk.tmp", should_exist=False), - FileExists( - "mess/keep_me.txt", - error_message=( - "Soubor keep_me.txt zmizel, a ten měl zůstat. Typická příčina je příliš široký " - "argument jako 'rm *', který smaže všechno v adresáři. Příště si nejdřív vypište, " - "co by takový příkaz zasáhl. Level obnovíte příkazem 'shellgame reset'." - ), - ), - ), - ) - success_message = "Správně! Jeden příkaz zvládne víc cest, ale odpovídáte za to, které soubory zasáhne." - - -@section.level(8) -class SectionChallengeLevel(Level): - solution = Solution( - steps=( - RunShell("mkdir -p myproject/src myproject/docs"), - RunShell("touch myproject/README.md"), - RunShell("rm delete_me.txt && rmdir empty_dir"), - ), - answer=None, - ) - title = "Souhrn Sekce 4" - instructions = """ - ### Výzva: Stavitel souborového systému - - Ukažte, že umíte vytvářet i mazat! - - ### Úkol - V `level-4/challenge`: - - 1. Vytvořte adresářovou strukturu: `myproject/src` a `myproject/docs` - 2. Vytvořte soubor `myproject/README.md` - 3. Smažte existující soubor `delete_me.txt` - 4. Smažte existující prázdný adresář `empty_dir` - - ### Shrnutí příkazů Sekce 4 - ``` - mkdir adresar → Nový adresář - mkdir -p a/b/c → Celá cesta najednou - touch soubor → Nový prázdný soubor - rm soubor → Smazat soubor - rmdir adresar → Smazat prázdný adresář - rm -r adresar → Smazat adresář s obsahem - ``` - - ### Odevzdání - Po splnění všech bodů spusťte `shellgame submit`. - """ - hints = [ - ("Rozdělte úkol na výsledky: dva adresáře a jeden soubor vytvořit, dvě existující položky odstranit."), - ( - "Vnořené adresáře vytvoří 'mkdir -p'; prázdný soubor 'touch'. " - "Soubor a prázdný adresář se mažou různými příkazy." - ), - ( - "Použijte 'mkdir -p myproject/src myproject/docs', 'touch myproject/README.md', " - "'rm delete_me.txt' a 'rmdir empty_dir'." - ), - ] - start_directory = "challenge" - fixture = WorkspaceFixture( - directories=("challenge/empty_dir",), - files=(FileFixture("challenge/delete_me.txt", "Delete this!\n"),), - clean=("challenge",), - ) - completion = Completion( - requirements=( - DirectoryExists( - "challenge/myproject/src", - error_message="Chybí adresář myproject/src. Zkontrolujte vytvořenou strukturu.", - ), - DirectoryExists( - "challenge/myproject/docs", - error_message="Chybí adresář myproject/docs.", - ), - FileExists( - "challenge/myproject/README.md", - error_message="Chybí soubor myproject/README.md. Zkontrolujte jeho název a umístění.", - ), - FileExists( - "challenge/delete_me.txt", - should_exist=False, - error_message="Soubor delete_me.txt stále existuje.", - ), - DirectoryExists( - "challenge/empty_dir", - should_exist=False, - error_message="Prázdný adresář empty_dir stále existuje.", - ), - ), - ) - success_message = "Brilantní! Dokončili jste Sekci 4. Umíte vytvářet i bourat!" diff --git a/src/shellgame/levels/sections/section5.py b/src/shellgame/levels/sections/section5.py deleted file mode 100644 index f76ed4d..0000000 --- a/src/shellgame/levels/sections/section5.py +++ /dev/null @@ -1,430 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -from typing_extensions import override - -from shellgame.levels.base import Level -from shellgame.levels.collector import Section -from shellgame.levels.completion import Completion, ExactAnswer, IntegerAnswer, TupleAnswer -from shellgame.levels.fixture import FileFixture, WorkspaceFixture -from shellgame.levels.solution import Solution - -section = Section(5, root="level-5") - - -@section.level(0) -class SectionIntroLevel(Level): - is_intro = True - title = "Sekce 5: Zkoumání souborů" - instructions_file = "section5_intro.md" - hints = ["Přečtěte si úvod a pokračujte stisknutím Enter."] - success_message = "Jdeme na to!" - - -@section.level(1) -class FileSizeInBytesLevel(Level): - solution = Solution(answer="12345") - title = "Velikost souboru" - instructions = """ - ### Cíl - Zjistěte přesnou velikost souboru v bajtech. - - Příkaz `ls -l` (long listing) zobrazí podrobné informace o souborech včetně jejich - velikosti v bajtech (5. sloupec). Přepínač `-h` ji převede do čitelnější podoby (KB, MB). - - ### Příkazy - - `ls -l` (zobrazí detaily; velikost je v pátém sloupci) - - ### Úkol - 1. V aktuálním adresáři je soubor `database.db`. - 2. Pomocí `ls -l` zjistěte jeho přesnou velikost v bajtech. - 3. Odevzdejte tuto velikost jako číslo. - - ### Odevzdání - `shellgame submit <bajty>` - """ - hints = [ - "Příkaz 'ls -l' zobrazí podrobnosti o souborech. Který sloupec obsahuje velikost?", - "Ve výstupu ls -l je velikost v bajtech - hledejte číslo před datem.", - "Použijte 'ls -l database.db' a podívejte se na pátý sloupec.", - ] - start_directory = "sizes" - fixture = WorkspaceFixture(files=(FileFixture("sizes/database.db", b"x" * 12345),)) - completion = Completion(answer=IntegerAnswer(12345)) - success_message = "Správně! Pátý sloupec `ls -l` udává velikost v bajtech; `-h` ji převede do čitelnější podoby." - - -@section.level(2) -class FindFileByExactSizeLevel(Level): - solution = Solution(answer="file_d") - title = "Hledání podle velikosti" - instructions = """ - ### Cíl - Najděte v podrobném výpisu soubor s konkrétní velikostí. - - ### Příkazy - - `ls -l` (zobrazí podrobnosti včetně velikosti v 5. sloupci) - - ### Úkol - V adresáři je několik souborů (`file_a`, `file_b`, `file_c`, `file_d`). - Pouze jeden z nich má velikost přesně **1337 bajtů**. - - 1. Spusťte `ls -l` a zkontrolujte sloupec s velikostí. - 2. Najděte soubor s velikostí přesně 1337 bajtů. - 3. Odevzdejte název tohoto souboru. - - ### Odevzdání - `shellgame submit <název_souboru>` - """ - hints = [ - "Příkaz 'ls -l' zobrazuje podrobný výpis souborů včetně velikosti v bajtech v pátém sloupci.", - "Spusťte 'ls -l' a hledejte řádek, kde je velikost přesně 1337.", - "Název souboru je na konci příslušného řádku. Odevzdejte ho příkazem 'shellgame submit <soubor>'.", - ] - start_directory = "search" - #: Extra practice of the 5.1 size column rather than a new skill, so a - #: confident player may skip it. - optional = True - fixture = WorkspaceFixture( - files=( - FileFixture("search/file_a", b"x" * 1000), - FileFixture("search/file_b", b"x" * 2000), - FileFixture("search/file_c", b"x" * 1338), - FileFixture("search/file_d", b"x" * 1337), - ) - ) - completion = Completion( - answer=ExactAnswer( - "file_d", - mistakes={ - "file_c": "Soubor 'file_c' má 1338 bajtů (o 1 bajt více). Hledejte přesně 1337 bajtů.", - "file_a": "Soubor 'file_a' má 1000 bajtů. Hledejte přesně 1337 bajtů.", - "file_b": "Soubor 'file_b' má 2000 bajtů. Hledejte přesně 1337 bajtů.", - }, - ) - ) - success_message = "Správně! Podrobný výpis se čte po sloupcích — stačí porovnat ten jeden, který vás zajímá." - - -@section.level(3) -class IdentifyJpegAmongFilesLevel(Level): - solution = Solution(answer="file3") - title = "Typ souboru" - instructions = """ - ### Cíl - Rozpoznejte obrázek mezi soubory bez přípony podle jejich obsahu. - - V Linuxu přípona souboru (např. `.txt`, `.jpg`) neurčuje jeho typ. O tom rozhoduje obsah. - Příkaz `file` prozkoumá obsah souboru a řekne vám, o jaký typ se jedná. - - ### Příkazy - - `file <soubor>` (zjistí typ konkrétního souboru) - - `file *` (zjistí typ všech souborů v aktuálním adresáři) - - ### Úkol - V adresáři jsou tři soubory bez přípony: `file1`, `file2`, `file3`. - Jeden z nich je obrázek (`JPEG image data`). Zjistěte který a odevzdejte jeho název. - - ### Odevzdání - `shellgame submit <soubor>` - """ - hints = [ - "Příkaz 'file' zkoumá obsah souboru, ne jeho název. Jak zjistíte typ všech souborů najednou?", - "Zkuste 'file *' nebo 'file file1 file2 file3'. Hledejte 'JPEG' ve výstupu.", - "Použijte 'file *' a najděte soubor označený jako 'JPEG image data'.", - ] - start_directory = "types" - fixture = WorkspaceFixture( - files=( - FileFixture("types/file1", "This is a text file."), - FileFixture("types/file2", b"\x00\x01\x02\x03"), - FileFixture("types/file3", b"\xff\xd8\xff\xe0\x00\x10JFIF"), - ) - ) - completion = Completion(answer=ExactAnswer("file3")) - success_message = "Správně! O typu souboru rozhoduje jeho obsah, ne název ani přípona." - - -@section.level(4) -class FindCriticalCodeInLogLevel(Level): - solution = Solution(answer="42") - title = "Hledání ve velkém souboru" - instructions = """ - ### Cíl - Naučte se prohlížet a prohledávat velké soubory pomocí interaktivního nástroje `less`. - - Příkaz `cat` vypíše celý soubor najednou, což je u dlouhých souborů nepraktické. - Interaktivní stránkovač `less` umožní souborem pohodlně listovat a hledat v něm. - - ### 🚪 Jak z less odejít: klávesa `q` (Quit) - Nástroj `less` zabere celou obrazovku terminálu. Během jeho běhu nelze zadávat - běžné příkazy shellu. - **Pro ukončení prohlížeče a návrat do příkazové řádky stiskněte klávesu `q`.** - - ### Ovládání less - ``` - q → UKONČIT prohlížení (návrat do shellu) - /hledany_text → Hledat text v souboru (potvrdit Enterem) - n → Další výskyt hledaného textu - Enter / šipka dolů → Posun o jeden řádek dolů - Mezerník / Page Down → Posun o celou stránku dolů - b / Page Up → Posun o stránku nahoru - šipka nahoru → Posun o řádek nahoru - g / G → Na začátek / na konec souboru - ``` - - ### Úkol - Soubor `server.log` má 200 řádků. Najděte v něm chybový kód na řádku s textem `CRITICAL`: - - 1. Otevřete soubor: `less server.log` - 2. Hledejte: stiskněte `/`, napište `CRITICAL` a stiskněte **Enter** - 3. Přečtěte chybový kód (číslo za slovem `Code`) na konci nalezeného řádku - 4. **Stiskněte klávesu `q`** pro ukončení `less` a návrat do příkazové řádky - 5. Odešlete nalezený kód pomocí `shellgame submit` - - ### Odevzdání - `shellgame submit <číslo>` - """ - hints = [ - "V less použijte / pro vyhledávání. Napište /CRITICAL a stiskněte Enter.", - "Nalezený řádek obsahuje číslo na konci. Přečtěte ho.", - "Pro návrat do shellu stiskněte klávesu 'q' (quit).", - "Pozor: neodevzdáváte číslo řádku (v závorkách na začátku), ale kód na konci věty.", - ] - start_directory = "logs" - completion = Completion( - answer=IntegerAnswer( - 42, - mistakes={137: "137 je číslo řádku, ne kód na konci. Přečtěte celý CRITICAL řádek."}, - error_message="Tohle není správný kód. Najděte řádek s 'CRITICAL' a přečtěte číslo na konci.", - invalid_message="Odpověď musí být číslo.", - ) - ) - success_message = "Správně! Less usnadňuje hledání ve velkých souborech." - - @override - def setup(self, workspace: Path) -> None: - lines: list[str] = [] - for i in range(1, 201): - if i == 137: - lines.append(f"[{i:03d}] CRITICAL: System failure detected - Code 42") - elif i % 10 == 0: - lines.append(f"[{i:03d}] WARNING: High memory usage") - elif i % 7 == 0: - lines.append(f"[{i:03d}] ERROR: Connection timeout") - else: - lines.append(f"[{i:03d}] INFO: Normal operation") - - FileFixture("logs/server.log", "\n".join(lines) + "\n").apply(self.section_path(workspace)) - - -@section.level(5) -class FindFakeJpgLevel(Level): - solution = Solution(answer="secret.jpg") - title = "Zamaskovaný soubor" - instructions = """ - ### Cíl - Najděte soubor s příponou `.jpg`, který ve skutečnosti není obrázkem. - - V adresáři `downloads` je mnoho různých stažených souborů (dokumenty, archivy, - skripty i logy). Někdo se pokusil ukrýt tajnou textovou zprávu tím, že ji pojmenoval - s příponou `.jpg`. - - ### Příkazy - - `file *.jpg` (zkontroluje pouze soubory s příponou .jpg místo všech položek) - - ### Úkol - 1. V adresáři `downloads` je mnoho různých souborů. Spuštění `file *` by vypsalo - desítky položek bez užitku. - 2. Pomocí `file *.jpg` zkontrolujte pouze soubory s příponou `.jpg`. - 3. Najděte, který soubor je ve skutečnosti textový (`ASCII text`). - 4. Odevzdejte název tohoto souboru. - - ### Odevzdání - `shellgame submit <soubor>` - """ - hints = [ - "Příkaz 'file' zkoumá skutečný obsah souboru bez ohledu na jeho příponu.", - "Místo 'file *' použijte 'file *.jpg' — zkontrolujete jen soubory s příponou .jpg.", - "Hledejte mezi soubory .jpg ten, u kterého 'file' vypíše 'ASCII text'. Odevzdejte jeho název.", - ] - start_directory = "downloads" - fixture = WorkspaceFixture( - files=( - # Real JPEGs - FileFixture("downloads/photo1.jpg", b"\xff\xd8\xff\xe0\x00\x10JFIF"), - FileFixture("downloads/photo2.jpg", b"\xff\xd8\xff\xe0\x00\x10JFIF"), - FileFixture("downloads/photo3.jpg", b"\xff\xd8\xff\xe0\x00\x10JFIF"), - FileFixture("downloads/photo4.jpg", b"\xff\xd8\xff\xe0\x00\x10JFIF"), - FileFixture("downloads/photo5.jpg", b"\xff\xd8\xff\xe0\x00\x10JFIF"), - FileFixture("downloads/vacation.jpg", b"\xff\xd8\xff\xe0\x00\x10JFIF"), - FileFixture("downloads/sunset.jpg", b"\xff\xd8\xff\xe0\x00\x10JFIF"), - FileFixture("downloads/wallpaper.jpg", b"\xff\xd8\xff\xe0\x00\x10JFIF"), - FileFixture("downloads/avatar.jpg", b"\xff\xd8\xff\xe0\x00\x10JFIF"), - FileFixture("downloads/nature.jpg", b"\xff\xd8\xff\xe0\x00\x10JFIF"), - # Disguised secret text file - FileFixture("downloads/secret.jpg", "This is actually a secret text file.\n"), - # Smoke files of various types (documents, archives, text, data) - FileFixture("downloads/manual.pdf", b"%PDF-1.4\n%EOF\n"), - FileFixture("downloads/archive.zip", b"PK\x03\x04\x14\x00\x00\x00\x08\x00"), - FileFixture("downloads/backup.tar.gz", b"\x1f\x8b\x08\x00\x00\x00\x00\x00"), - FileFixture("downloads/music.mp3", b"ID3\x03\x00\x00\x00"), - FileFixture("downloads/notes.txt", "Random notes and meeting minutes.\n"), - FileFixture("downloads/install.sh", "#!/bin/sh\necho 'installing...'\n"), - FileFixture("downloads/setup.log", "[INFO] Setup initialized.\n"), - FileFixture("downloads/table.csv", "id,name,value\n1,alpha,100\n"), - FileFixture("downloads/data.json", '{"status": "ok", "count": 42}\n'), - FileFixture("downloads/config.yaml", "env: production\ndebug: false\n"), - FileFixture("downloads/readme.md", "# Downloads Readme\nJust downloads.\n"), - FileFixture("downloads/styles.css", "body { margin: 0; padding: 0; }\n"), - FileFixture("downloads/index.html", "<!DOCTYPE html><html><body>Test</body></html>\n"), - FileFixture("downloads/script.py", "#!/usr/bin/env python3\nprint('hello')\n"), - FileFixture( - "downloads/checksums.sha256", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\n" - ), - FileFixture("downloads/report.doc", b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"), - FileFixture("downloads/app.bin", b"\x7fELF\x02\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00"), - ) - ) - completion = Completion(answer=ExactAnswer("secret.jpg")) - success_message = "Správně! Přípona je jen dohoda — `file` čte skutečný obsah, a tak odhalí i zamaskovaný soubor." - - -@section.level(6) -class IdentifyPythonScriptLevel(Level): - solution = Solution(answer="calc.py") - title = "Rozpoznání Python skriptu" - instructions = """ - ### Cíl - Rozpoznejte Python skript mezi různými typy souborů. - - Příkaz `file` dokáže podle obsahu spolehlivě rozpoznat Python skript i bez ohledu na jeho název. - Popis `Python script` ale neříká, zda má soubor právo ke spuštění (`x`) — typ zjišťuje `file`, - kdežto oprávnění zobrazuje `ls -l` a mění `chmod` (tomu se věnuje Sekce 8). - - ### Příkazy - - `file *` (vypíše typy všech souborů v aktuálním adresáři) - - ### Úkol - Nacházíte se přímo v adresáři `bin` (nikam nemusíte přecházet): - 1. Prozkoumejte soubory v aktuálním adresáři pomocí `file *`. - 2. Najděte soubor, který příkaz `file` označí jako `Python script`. - 3. Odevzdejte název tohoto souboru. - - ### Odevzdání - `shellgame submit <soubor>` - """ - hints = [ - "Jste přímo v adresáři 'bin'. Spusťte 'file *' pro zjištění typů všech souborů.", - "Spusťte 'file *' a hledejte soubor, u kterého výstup uvádí 'Python script'.", - "Odevzdejte název souboru označeného jako 'Python script'; samotný popis neověřuje právo x.", - ] - start_directory = "bin" - fixture = WorkspaceFixture( - files=( - FileFixture("bin/readme.txt", "Just text."), - FileFixture("bin/run.sh", "#!/bin/bash\necho hello"), - FileFixture("bin/calc.py", "#!/usr/bin/env python3\nprint(1+1)\n"), - FileFixture("bin/program", b"\x7fELF"), - ) - ) - completion = Completion( - answer=ExactAnswer( - "calc.py", - mistakes={ - "readme.txt": "To je prostý text, ne skript. Řiďte se přesným popisem z `file *`, ne příponou názvu.", - "run.sh": "To je shellový skript (`shell script`), ne Python. Přečtěte popis z `file *` celý.", - "program": "To je zkompilovaný program (ELF), ne skript. Porovnejte popisy z `file *`.", - }, - error_message=( - "To není Python skript. Rozhoduje přesný popis z `file *`; " - "právo `x` z něj nevyčtete — to ukazuje až `ls -l`." - ), - ) - ) - success_message = "Správně! `file` určí typ souboru podle obsahu, ale právo ke spuštění ukáže až `ls -l`." - - -@section.level(7) -class FileDetectiveChallengeLevel(Level): - title = "Souhrn Sekce 5" - instructions = """ - ### Výzva: Detektiv souborů - - Ukažte, že umíte identifikovat typy souborů! - - ### Úkol - V `level-5/mystery` je 5 souborů s podivnými názvy. - Zjistěte typ každého a odpovězte na otázky: - - 1. Kolik je tam **prostých textových** souborů (popis začíná `ASCII text`, bez skriptů)? - 2. Kolik je tam **obrázků** (image)? - 3. Jaký je název jediného **Python** skriptu (bez cesty)? - - ### Formát odpovědi - `<text_count>,<image_count>,<script_name>` - - Skript počítejte samostatně, i když jeho popis také obsahuje `ASCII text`. - - ### Shrnutí příkazů Sekce 5 - ``` - file soubor → Zjistí typ souboru - file * → Typy všech souborů - file -b soubor → Jen typ bez názvu - ``` - - ### Odevzdání - `shellgame submit <text>,<img>,<script>` - """ - hints = [ - "Použijte 'file *' k zobrazení typů všech souborů najednou.", - "Pro prostý text hledejte popis začínající 'ASCII text'. Skripty do tohoto počtu nepatří.", - ("Popis s 'image' znamená obrázek. Popis s 'Python script' znamená Python skript, ne prostý text."), - ] - start_directory = "mystery" - fixture = WorkspaceFixture( - clean=("mystery",), - files=( - FileFixture("mystery/data.bin", "This is just plain text.\n"), - FileFixture("mystery/notes.xyz", "More text content.\n"), - FileFixture( - "mystery/config.txt", - bytes.fromhex( - "89504e470d0a1a0a0000000d4948445200000001000000010802000000907753de" - "0000000c49444154789c63606060000000040001f61738550000000049454e44ae426082" - ), - ), - FileFixture( - "mystery/analyzer.dat", - "#!/usr/bin/env python3\nimport sys\nprint('Hello')\n", - ), - FileFixture("mystery/readme.doc", b"\x7fELF\x02\x01\x01"), - ), - ) - completion = Completion( - answer=TupleAnswer( - ( - IntegerAnswer( - 2, - error_message=( - "Počet prostých textových souborů není správně. " - "Počítejte popisy začínající 'ASCII text', nikoli skripty." - ), - invalid_message="První dvě hodnoty musí být čísla.", - ), - IntegerAnswer( - 1, - error_message="Počet obrázků není správně. Hledejte 'image' ve výstupu 'file *'.", - invalid_message="První dvě hodnoty musí být čísla.", - ), - ExactAnswer( - "analyzer.dat", - case_sensitive=False, - error_message="Název Python skriptu není správně. Hledejte 'Python' ve výstupu 'file *'.", - ), - ), - format_message="Formát: počet_textových,počet_obrázků,název_skriptu", - ) - ) - success_message = "Skvělá detektivní práce! Dokončili jste Sekci 5. Příponám se už nedáte zmást!" diff --git a/src/shellgame/levels/sections/section6.py b/src/shellgame/levels/sections/section6.py deleted file mode 100644 index 2f38fcd..0000000 --- a/src/shellgame/levels/sections/section6.py +++ /dev/null @@ -1,437 +0,0 @@ -from __future__ import annotations - -from shellgame.levels.base import Level -from shellgame.levels.collector import Section -from shellgame.levels.completion import ( - Completion, - ExactAnswer, - FileExists, - PathMoved, - PathsMatch, - TextFileContent, -) -from shellgame.levels.fixture import FileFixture, WorkspaceFixture -from shellgame.levels.solution import RunShell, Solution - -section = Section(6, root="level-6") - - -@section.level(0) -class SectionIntro(Level): - is_intro = True - title = "Sekce 6: Kopírování a přesouvání" - instructions_file = "section6_intro.md" - hints = ["Přečtěte si úvod a pokračujte stisknutím Enter."] - success_message = "Jdeme na to!" - - -@section.level(1) -class BackupImportantFileLevel(Level): - solution = Solution(steps=(RunShell("cp dulezite.txt dulezite.bak"),), answer="dulezite.bak") - title = "Kopírování souboru" - instructions = """ - Příkaz `cp` (copy) vytvoří kopii souboru. - - ### Proč zálohovat? - Před úpravou důležitého souboru je dobré si udělat zálohu. - Když něco pokazíte, máte se kam vrátit! - - V praxi uvidíte: - - `cp config.yaml config.yaml.bak` (před úpravou konfigurace) - - `cp report.docx report_v1.docx` (verzování dokumentů) - - ### Úkol - 1. Začínáte v `level-6/copying`. Vytvořte zálohu souboru `dulezite.txt`. Kopii pojmenujte `dulezite.bak`. - 2. Ověřte výsledek pomocí `ls`. - - ## Příkazy: - - `cp <zdroj> <cíl>`: Zkopíruje zdroj do cíle - - ## Odevzdání: - Po vytvoření zálohy spusťte: - `shellgame submit` - (můžete také zadat: `shellgame submit dulezite.bak`) - """ - hints = [ - "Příkaz cp má dva argumenty: odkud a kam kopírujete.", - "Syntaxe je: cp zdrojový_soubor cílový_soubor", - "Použijte 'cp dulezite.txt dulezite.bak'.", - ] - start_directory = "copying" - fixture = WorkspaceFixture( - files=(FileFixture("copying/dulezite.txt", "Very important data."),), - clean=("copying/dulezite.bak",), - ) - completion = Completion( - answer=ExactAnswer( - "dulezite.bak", - mistakes={ - "dulezite.txt": ( - "Odevzdali jste název původního souboru, ne jeho kopie. " - "Příkaz cp bere nejdřív zdroj a potom cíl: 'cp zdroj cíl'." - ), - }, - ), - requirements=( - PathsMatch( - "copying/dulezite.txt", - "copying/dulezite.bak", - ), - ), - allow_empty=True, - ) - success_message = "Správně! Záloha je samostatná kopie pod novým jménem, originál zůstává nedotčený." - - -@section.level(2) -class BackupProjectDirectoryLevel(Level): - solution = Solution(steps=(RunShell("cp -r projekt projekt_zaloha"),), answer="projekt_zaloha") - title = "Kopírování adresáře" - instructions = """ - Pro kopírování adresářů musíte použít přepínač `-r` (recursive), aby se zkopíroval i jejich obsah. - - ## Úkol: - Zkopírujte celý adresář `projekt` do nového adresáře `projekt_zaloha`. - - ## Příkazy: - - `cp -r <zdroj> <cíl>`: Zkopíruje adresář - - ## Odevzdání: - Po zkopírování adresáře spusťte: - `shellgame submit` - (můžete také zadat: `shellgame submit projekt_zaloha`) - """ - hints = [ - "Pro kopírování celého adresáře včetně obsahu je nutné použít rekurzivní přepínač '-r'.", - "Tvar je 'cp -r <zdroj> <cíl>': přepínač stojí před oběma argumenty, " - "zdrojový adresář je první a název nové kopie druhý.", - "Spusťte 'cp -r projekt projekt_zaloha'.", - ] - start_directory = "copying" - fixture = WorkspaceFixture( - files=(FileFixture("copying/projekt/main.py", "print('hello')"),), - clean=("copying/projekt_zaloha",), - ) - completion = Completion( - answer=ExactAnswer("projekt_zaloha"), - requirements=( - PathsMatch( - "copying/projekt", - "copying/projekt_zaloha", - ), - ), - allow_empty=True, - ) - success_message = "Správně! Přepínač -r říká příkazu cp, že má sestoupit do celého stromu adresáře." - - -@section.level(3) -class RenameFileLevel(Level): - solution = Solution(steps=(RunShell("mv spatne_jmeno.txt spravne_jmeno.txt"),), answer="spravne_jmeno.txt") - title = "Přejmenování souboru" - instructions = """ - Příkaz `mv` (move) se používá k přesouvání, - ale pokud přesouváte soubor ve stejném adresáři na nové jméno, jde o přejmenování. - - ## Úkol: - Soubor `spatne_jmeno.txt` má překlep. Přejmenujte ho na `spravne_jmeno.txt`. - - ## Příkazy: - - `mv <staré_jméno> <nové_jméno>`: Přejmenuje soubor - - ## Odevzdání: - Po přejmenování spusťte: - `shellgame submit` - (můžete také zadat: `shellgame submit spravne_jmeno.txt`) - """ - hints = [ - "Příkaz 'mv' slouží nejen k přesunu, ale i k přejmenování souboru: 'mv staré nové'.", - "Oba argumenty jsou jména souborů v aktuálním adresáři: první je to současné, druhé to požadované.", - "Spusťte 'mv spatne_jmeno.txt spravne_jmeno.txt'.", - ] - start_directory = "moving" - fixture = WorkspaceFixture( - files=(FileFixture("moving/spatne_jmeno.txt", "content"),), - clean=("moving/spravne_jmeno.txt",), - ) - completion = Completion( - answer=ExactAnswer("spravne_jmeno.txt"), - requirements=( - PathMoved( - "moving/spatne_jmeno.txt", - "moving/spravne_jmeno.txt", - ), - ), - allow_empty=True, - ) - success_message = "Správně! Přejmenování je jen přesun na novou cestu ve stejném adresáři." - - -@section.level(4) -class MoveReportToDocumentsLevel(Level): - solution = Solution(steps=(RunShell("mv report.pdf dokumenty/"),), answer="dokumenty") - title = "Přesun souboru" - instructions = """ - Pokud jako cíl příkazu `mv` uvedete existující adresář, - soubor se do něj přesune (a zachová si své jméno, pokud neuvedete jiné). - - ## Úkol: - Přesuňte soubor `report.pdf` do adresáře `dokumenty`. - - ## Příkazy: - - `mv <soubor> <adresář>/`: Přesune soubor do adresáře - - ## Proč to lomítko na konci? - Pokud cílový adresář **neexistuje**, `mv` soubor tiše přejmenuje na zadané jméno. - S lomítkem `mv` místo toho ohlásí chybu, takže se o překlepu v názvu adresáře dozvíte hned. - - ## Odevzdání: - Po přesunu souboru spusťte: - `shellgame submit` - (můžete také zadat: `shellgame submit dokumenty`) - """ - hints = [ - "O tom, co 'mv' udělá, rozhoduje podoba cíle: soubor znamená přejmenování, existující adresář přesun.", - "Tvar je 'mv <soubor> <cílový_adresář>/': první argument je přesouvaný soubor, " - "druhý cílový adresář zakončený lomítkem.", - "Spusťte 'mv report.pdf dokumenty/'. Lomítko na konci značí adresář.", - ] - start_directory = "moving" - fixture = WorkspaceFixture( - directories=("moving/dokumenty",), - files=(FileFixture("moving/report.pdf", "report data"),), - clean=("moving/dokumenty/report.pdf",), - ) - completion = Completion( - answer=ExactAnswer("dokumenty"), - requirements=( - PathMoved( - "moving/report.pdf", - "moving/dokumenty/report.pdf", - source_error=( - "report.pdf je stále na původním místě. Kopie originál ponechává, přesun ho odtud odebere." - ), - destination_error=( - "report.pdf z původního místa zmizel, ale v cílovém adresáři není. " - "Když cíl neexistuje, 'mv' soubor jen přejmenuje - zkontrolujte 'ls'." - ), - ), - ), - allow_empty=True, - ) - success_message = "Správně! Podoba cíle rozhoduje, jestli mv přejmenovává, nebo přesouvá." - - -@section.level(5) -class MoveDirectoryLevel(Level): - solution = Solution(steps=(RunShell("mv projekt archiv/"),), answer=None) - title = "Přesun adresáře" - instructions = """ - Příkaz `mv` přesouvá celé adresáře včetně jejich obsahu; přepínač `-r` nepotřebuje. - - ## Úkol: - Přesuňte celý adresář `projekt` do existujícího adresáře `archiv`. - - ## Příkazy: - - `mv <adresář> <cílový_adresář>/` - - ## Odevzdání: - Až bude `projekt` uvnitř `archiv`, spusťte: - `shellgame submit` - """ - hints = [ - "Adresář se přesouvá stejným příkazem jako soubor: 'mv' vezme celý podstrom a přepínač '-r' nepotřebuje.", - "Tvar je 'mv <adresář> <cílový_adresář>/': první argument je přesouvaný adresář, " - "druhý už existující rodič, do kterého má vklouznout.", - "Spusťte 'mv projekt archiv/' a ověřte výsledek pomocí 'ls archiv/'.", - ] - start_directory = "moving" - fixture = WorkspaceFixture( - directories=("moving/archiv",), - files=(FileFixture("moving/projekt/data/file.txt", "content"),), - clean=("moving/projekt", "moving/archiv/projekt"), - ) - completion = Completion( - requirements=( - PathMoved( - "moving/projekt", - "moving/archiv/projekt", - source_error="Adresář projekt je stále na původním místě - zatím se nic nepřesunulo.", - destination_error=( - "Projekt z původního místa zmizel, ale v archivu není. " - "Když cílový adresář neexistuje, 'mv' zdroj jen přejmenuje - porovnejte 'ls' a 'ls archiv/'." - ), - ), - TextFileContent( - "moving/archiv/projekt/data/file.txt", - exact="content", - error_message="Přesunutý adresář nemá původní obsah.", - ), - ), - ) - success_message = "Správně! Přesun adresáře vezme celý jeho obsah s sebou jedním krokem." - - -@section.level(6) -class OrganizeLogsLevel(Level): - solution = Solution(steps=(RunShell("mv app.log error.log logs/"),), answer="logs") - title = "Úklid logů" - instructions = """ - V adresáři je nepořádek. Všechny soubory s příponou `.log` by měly být v adresáři `logs`. - - ## Úkol: - Přesuňte všechny `.log` soubory (`app.log`, `error.log`) do adresáře `logs`. - Zvládnete to jedním příkazem, když místo výčtu jmen zadáte vzor se žolíkem `*`. - - ## Příkazy: - - `mv VZOR logs/`: za `VZOR` dosaďte vzor, který vybere právě `.log` soubory - - Vzor si nejdřív ověřte pomocí `ls VZOR` - uvidíte přesně ty soubory, které by `mv` přesunul. - Znak `*` je žolík (zástupný znak); celou rodinu žolíků probereme hned v následující Sekci 7. - - ## Odevzdání: - Odevzdejte název adresáře, kam jste soubory přesunuli. - `shellgame submit logs` - """ - hints = [ - "Žolík '*' nahradí libovolný počet znaků, takže vzor '*.log' vybere všechny soubory s touto příponou.", - "Příkaz 'mv' dokáže přesunout více souborů najednou do cílového adresáře: 'mv <soubory> <cíl>/'.", - "Spusťte 'mv *.log logs/' a odevzdejte 'logs'.", - ] - start_directory = "organize" - fixture = WorkspaceFixture( - directories=("organize/logs",), - files=( - FileFixture("organize/app.log", "log1"), - FileFixture("organize/error.log", "log2"), - FileFixture("organize/other.txt", "keep me"), - ), - clean=("organize/logs",), - ) - completion = Completion( - answer=ExactAnswer("logs"), - requirements=( - PathMoved( - "organize/app.log", - "organize/logs/app.log", - ), - PathMoved( - "organize/error.log", - "organize/logs/error.log", - ), - FileExists("organize/other.txt"), - ), - ) - success_message = "Správně! Vzor se žolíkem rozbalí shell na seznam jmen ještě před spuštěním příkazu." - - -@section.level(7) -class ShellgameAliasLevel(Level): - title = "Rychlejší práce: alias" - instructions = """ - ### Cíl - Zkraťte si časté psaní příkazu `shellgame` pomocí aliasu `sg`. - - ### Nastavení - Použijte příkaz pro svůj shell: - - **Bash:** `alias sg='shellgame'` - - **Fish:** `alias sg shellgame` - - Alias ověřte příkazem `type sg`. - Platí jen v aktuálním herním shellu; po jeho ukončení zmizí. - - ### Odevzdání - Dokončete level přes vytvořený alias: - `sg submit alias-ready` - - V dalších levelech můžete místo dlouhého `shellgame` používat kratší `sg`. - Samotné použití `sg` je součást tohoto cvičení. - """ - hints = [ - "Alias dává dlouhému příkazu kratší jméno. Vyberte syntaxi pro svůj shell.", - "Po nastavení spusťte 'type sg' a ověřte, že sg odkazuje na shellgame.", - "Level dokončete přes alias příkazem 'sg submit alias-ready'.", - ] - completion = Completion( - answer=ExactAnswer( - "alias-ready", - required_message="Nejprve nastavte a ověřte alias, potom spusťte: sg submit alias-ready", - ) - ) - success_message = "Výborně! Alias je jen krátké jméno pro delší příkaz — v závěrečné výzvě se vám bude hodit." - - -@section.level(8) -class FileOrganizerChallengeLevel(Level): - solution = Solution( - steps=(RunShell("cp original.txt backup/ && mv temp_data.csv data.csv && mv misplaced.log logs/"),), - answer=None, - ) - title = "Výzva: Organizátor souborů" - instructions = """ - Ukažte, že ovládáte kopírování a přesouvání! - - ### Úkol - V aktuálním adresáři: - - 1. **Zkopírujte** `original.txt` do složky `backup/` (zachovejte originál) - 2. **Přejmenujte** `temp_data.csv` na `data.csv` - 3. **Přesuňte** `misplaced.log` do složky `logs/` - - ### Přehled příkazů - ``` - cp zdroj cíl → Kopíruje soubor - cp zdroj dir/ → Kopíruje do adresáře - cp -r dir1 dir2 → Kopíruje celý adresář - mv zdroj cíl → Přesune/přejmenuje - mv soubor dir/ → Přesune do adresáře - ``` - - ### Odevzdání - Po splnění všech bodů spusťte `shellgame submit`. - """ - hints = [ - "Rozhodněte u každého souboru, zda musí původní cesta zůstat zachovaná. Podle toho zvolte 'cp' nebo 'mv'.", - "Přejmenování i přesun používají 'mv'; rozdíl určuje podoba cíle. Kopie do adresáře zachová původní soubor.", - "Použijte 'cp original.txt backup/', 'mv temp_data.csv data.csv' a 'mv misplaced.log logs/'.", - ] - start_directory = "final_test" - fixture = WorkspaceFixture( - directories=("final_test/backup", "final_test/logs"), - files=( - FileFixture( - "final_test/original.txt", - "Important data - do not delete!\n", - ), - FileFixture("final_test/temp_data.csv", "col1,col2\n1,2\n"), - FileFixture("final_test/misplaced.log", "Log entry\n"), - ), - clean=("final_test",), - ) - completion = Completion( - requirements=( - FileExists( - "final_test/original.txt", - error_message="Smazali jste original.txt! Měli jste ho zkopírovat, ne přesunout.", - ), - PathsMatch( - "final_test/original.txt", - "final_test/backup/original.txt", - error_message="Kopie v backup/ neodpovídá originálu.", - destination_error="Chybí kopie original.txt v adresáři backup/.", - ), - PathMoved( - "final_test/temp_data.csv", - "final_test/data.csv", - source_error="temp_data.csv stále existuje. Přejmenujte ho na data.csv pomocí 'mv'.", - destination_error="Chybí data.csv. Zkontrolujte, zda jste soubor přejmenovali místo kopírování.", - ), - PathMoved( - "final_test/misplaced.log", - "final_test/logs/misplaced.log", - source_error="misplaced.log stále v hlavní složce. Přesuňte do logs/ pomocí 'mv'.", - destination_error="Chybí misplaced.log v adresáři logs/.", - ), - ), - ) - success_message = "Výborně! Dokončili jste Sekci 6 — kopírování, přesun i přejmenování máte pod kontrolou." diff --git a/src/shellgame/levels/sections/section7.py b/src/shellgame/levels/sections/section7.py deleted file mode 100644 index e716678..0000000 --- a/src/shellgame/levels/sections/section7.py +++ /dev/null @@ -1,343 +0,0 @@ -from __future__ import annotations - -from shellgame.levels.base import Level -from shellgame.levels.collector import Section -from shellgame.levels.completion import Completion, FileExists, IntegerAnswer, TupleAnswer -from shellgame.levels.fixture import FileFixture, WorkspaceFixture -from shellgame.levels.solution import RunShell, Solution - -section = Section(7, root="level-7") - - -@section.level(0) -class SectionIntro(Level): - is_intro = True - title = "Sekce 7: Žolíky (Wildcards)" - instructions_file = "section7_intro.md" - hints = ["Přečtěte si úvod a pokračujte stisknutím Enter."] - success_message = "Jdeme na to!" - - -@section.level(1) -class StarWildcardCopyLevel(Level): - solution = Solution(steps=(RunShell("cp *.jpg images/"),), answer=None) - title = "Hvězdička *" - instructions = """ - # Hvězdička * - - Hvězdička `*` nahradí JAKOUKOLIV sekvenci znaků (včetně prázdné). - Je velmi užitečná pro výběr souborů se specifickou příponou. - - Potkali jste ji už dříve (`file *`, `mv *.log logs/`); tady si její pravidla poprvé pojmenujeme přesně. - - ### Proč je to užitečné - Představte si, že máte 100 fotografií a chcete je všechny zkopírovat. - Místo `cp foto1.jpg foto2.jpg foto3.jpg ...` stačí `cp *.jpg cíl/`. - - ### Úkol - Zkopírujte všechny soubory s příponou `.jpg` do adresáře `images`. - (Adresář `images` již existuje). - - ### Příkazy - - `cp *.jpg adresář/` - zkopíruje všechny .jpg soubory - - ### Odevzdání - Po splnění úkolu odevzdejte: `shellgame submit` - """ - hints = [ - "Hvězdička (*) nahrazuje libovolný počet znaků.", - "Příkaz 'ls *.jpg' vypíše všechny soubory s příponou .jpg.", - "Použijte 'cp *.jpg images/' pro zkopírování všech souborů s příponou .jpg do adresáře images.", - ] - start_directory = "star" - fixture = WorkspaceFixture( - directories=("star/images",), - files=( - FileFixture("star/photo1.jpg"), - FileFixture("star/photo2.jpg"), - FileFixture("star/notes.txt"), - ), - clean=("star/images",), - ) - completion = Completion( - requirements=( - FileExists("star/images/photo1.jpg"), - FileExists("star/images/photo2.jpg"), - FileExists("star/images/notes.txt", should_exist=False), - ) - ) - success_message = ( - "Správně! Jediný vzor s hvězdičkou zastoupil libovolný zbytek názvu, " - "takže jste nemuseli soubory vypisovat po jednom." - ) - - -@section.level(2) -class QuestionMarkWildcardCopyLevel(Level): - solution = Solution(steps=(RunShell("cp data?.txt short_data/"),), answer=None) - title = "Otazník ?" - instructions = """ - # Otazník ? - - Otazník `?` nahradí PRÁVĚ JEDEN znak. - Je užitečný, když chcete být přesnější než s hvězdičkou. - - ### Požadavek: Bash - Tento level vyžaduje **Bash**. Ve fish použijte variantu s `bash -c` níže. - Ta spustí pouze kopírování v Bashi; odevzdávejte dál ve svém herním shellu. - - ### Rozdíl od hvězdičky - - `*` = libovolný počet znaků (0 nebo více) - - `?` = přesně jeden znak - - ### Úkol - Zkopírujte `data1.txt` a `data2.txt` do adresáře `short_data/`. - NEKOPÍRUJTE `data10.txt` (má dvouciferné číslo). - - ### Příkazy - - V Bashi: `cp VZOR short_data/` - VZOR sestavte sami z `data`, žolíku a `.txt` - - Z fish: `bash -c 'cp VZOR short_data/'` - uvozovky ponechte - - Vzor nejdřív ověřte pomocí `ls VZOR`: vypsat se smějí jen soubory, které chcete zkopírovat. - - ### Odevzdání - Po splnění úkolu odevzdejte: `shellgame submit` - """ - hints = [ - "Otazník nahradí právě jeden znak. Kolik znaků je mezi 'data' a '.txt' v data1.txt?", - "data?.txt zachytí data1.txt a data2.txt, ale ne data10.txt (tam jsou dva znaky).", - "V Bashi použijte `cp data?.txt short_data/`. Z fish: `bash -c 'cp data?.txt short_data/'`.", - ] - start_directory = "question" - fixture = WorkspaceFixture( - directories=("question/short_data",), - files=( - FileFixture("question/data1.txt"), - FileFixture("question/data2.txt"), - FileFixture("question/data10.txt"), - ), - clean=("question/short_data",), - ) - completion = Completion( - requirements=( - FileExists("question/short_data/data1.txt"), - FileExists("question/short_data/data2.txt"), - FileExists("question/short_data/data10.txt", should_exist=False), - ) - ) - success_message = "Správně! Otazník zastupuje právě jeden znak, proto se dvouciferný název do výběru nedostal." - - -@section.level(3) -class CharacterClassWildcardCopyLevel(Level): - solution = Solution(steps=(RunShell("cp file_[ab].txt ab_files/"),), answer=None) - title = "Výběr znaků []" - instructions = """ - # Výběr znaků [] - - Hranaté závorky `[...]` nahradí JEDEN ze znaků uvnitř. - Například `[abc]` odpovídá znaku 'a', 'b' nebo 'c'. - - ### Požadavek: Bash - Tento level vyžaduje **Bash**. Ve fish použijte variantu s `bash -c` níže. - Ta spustí pouze kopírování v Bashi; odevzdávejte dál ve svém herním shellu. - - ### Příklady - - `zprava_[xy].txt` → zprava_x.txt, zprava_y.txt - - `log[123].txt` → log1.txt, log2.txt, log3.txt - - ### Úkol - Zkopírujte `file_a.txt` a `file_b.txt` do adresáře `ab_files/`. - NEKOPÍRUJTE `file_c.txt`. - - ### Příkazy - - V Bashi: `cp VZOR ab_files/` - VZOR sestavte sami z `file_`, závorek s povolenými znaky a `.txt` - - Z fish: `bash -c 'cp VZOR ab_files/'` - uvozovky ponechte - - Vzor nejdřív ověřte pomocí `ls VZOR`: vypsat se smějí jen soubory, které chcete zkopírovat. - - ### Odevzdání - Po splnění úkolu odevzdejte: `shellgame submit` - """ - hints = [ - "Hranaté závorky definují množinu povolených znaků na dané pozici.", - "[ab] znamená 'a nebo b', takže file_[ab].txt zachytí file_a.txt a file_b.txt.", - "V Bashi použijte `cp file_[ab].txt ab_files/`. Z fish: `bash -c 'cp file_[ab].txt ab_files/'`.", - ] - start_directory = "brackets" - fixture = WorkspaceFixture( - directories=("brackets/ab_files",), - files=( - FileFixture("brackets/file_a.txt"), - FileFixture("brackets/file_b.txt"), - FileFixture("brackets/file_c.txt"), - ), - clean=("brackets/ab_files",), - ) - completion = Completion( - requirements=( - FileExists("brackets/ab_files/file_a.txt"), - FileExists("brackets/ab_files/file_b.txt"), - FileExists("brackets/ab_files/file_c.txt", should_exist=False), - ) - ) - success_message = ( - "Správně! Závorky povolují na jedné pozici jen vyjmenované znaky, takže vzor vybral přesně požadovanou dvojici." - ) - - -@section.level(4) -class RangeWildcardCopyLevel(Level): - solution = Solution(steps=(RunShell("cp [[:lower:]]*.txt lowercase/"),), answer=None) - title = "Třídy znaků [[:lower:]]" - instructions = """ - # Třídy znaků [[:lower:]] - - Rozsahy jako `[a-z]` závisejí na řazení nastaveného jazyka (locale). - POSIX třída `[[:lower:]]` místo pořadí vybírá jeden znak klasifikovaný - jako malé písmeno. Pro tento úkol je proto spolehlivější napříč locale. - - Podobně existují `[[:upper:]]` pro velká písmena a `[[:digit:]]` pro číslice. - - ### Požadavek: Bash - Tento level vyžaduje **Bash**. Ve fish použijte variantu s `bash -c` níže. - Ta spustí pouze kopírování v Bashi; odevzdávejte dál ve svém herním shellu. - - ### Úkol - Zkopírujte všechny soubory `.txt` začínající malým písmenem do adresáře `lowercase/`. - NEKOPÍRUJTE soubory začínající velkým písmenem. - - ### Příkazy - - V Bashi: `cp VZOR lowercase/` - VZOR sestavte sami z třídy znaků, `*` a `.txt` - - Z fish: `bash -c 'cp VZOR lowercase/'` - uvozovky ponechte - - Vzor nejdřív ověřte pomocí `ls VZOR`: vypsat se smějí jen soubory, které chcete zkopírovat. - - Přípona `.txt` vyloučí cílový adresář `lowercase`, který také začíná malým písmenem. - - ### Odevzdání - Po splnění úkolu odevzdejte: `shellgame submit` - """ - hints = [ - "Třída [[:lower:]] vybere na dané pozici právě jedno malé písmeno bez závislosti na pořadí znaků v locale.", - "Vzor [[:lower:]]*.txt vybere názvy začínající malým písmenem a končící příponou .txt.", - "V Bashi použijte `cp [[:lower:]]*.txt lowercase/`. Z fish: `bash -c 'cp [[:lower:]]*.txt lowercase/'`.", - ] - start_directory = "ranges" - fixture = WorkspaceFixture( - directories=("ranges/lowercase",), - files=( - FileFixture("ranges/apple.txt"), - FileFixture("ranges/Banana.txt"), - FileFixture("ranges/cherry.txt"), - FileFixture("ranges/Date.txt"), - ), - clean=("ranges/lowercase",), - ) - completion = Completion( - requirements=( - FileExists("ranges/lowercase/apple.txt"), - FileExists("ranges/lowercase/cherry.txt"), - FileExists("ranges/lowercase/Banana.txt", should_exist=False), - FileExists("ranges/lowercase/Date.txt", should_exist=False), - ) - ) - success_message = ( - "Správně! Třída znaků vybírá podle klasifikace písmene, " - "takže výsledek nezávisí na řazení znaků v nastaveném locale." - ) - - -@section.level(5) -class WildcardsChallengeLevel(Level): - solution = Solution( - steps=( - RunShell("cp *.log logs/"), - RunShell("cp data?.txt short_data/"), - RunShell("cp report_[ab].csv selected_reports/"), - ), - answer="3,2,2", - ) - title = "Souhrn Sekce 7" - instructions = """ - ### Výzva: Tři druhy žolíků - - ### Úkol - V aktuálním adresáři použijte pro každý výběr jiný druh vzoru. - Než začnete kopírovat, nejprve si předpovězte odpovídající názvy a vzor ověřte pomocí `ls VZOR`. - - 1. Pomocí `*` zkopírujte všechny `.log` soubory do `logs/`. - 2. Pomocí `?` zkopírujte do `short_data/` jen názvy `data`, jeden znak a `.txt`. - 3. Pomocí znakové třídy zkopírujte do `selected_reports/` reporty s písmenem `a` nebo `b`. - - Nakonec odevzdejte počty souborů v těchto třech cílových adresářích. - Formát: `<logy>,<kratka_data>,<vybrane_reporty>` - - `?` a `[...]` zde používají syntaxi Bashe. Ve fish spusťte kopírování přes `bash -c`. - - ### Odevzdání - `shellgame submit <logy>,<data>,<reporty>` - """ - hints = [ - "Každý krok má procvičit jiný vzor. Nejdřív si pomocí 'ls' ověřte, zda vzor nevybírá některý z decoy souborů.", - "Logy potřebují žolík pro libovolný zbytek názvu, krátká data žolík pro právě jeden znak " - "a reporty znakovou třídu se dvěma povolenými písmeny; každý sestavený vzor ověřte pomocí `ls`.", - "Bash: `cp *.log logs/; cp data?.txt short_data/; cp report_[ab].csv selected_reports/`. " - "Fish: `bash -c 'cp *.log logs/; cp data?.txt short_data/; cp report_[ab].csv selected_reports/'`.", - ] - start_directory = "challenge" - fixture = WorkspaceFixture( - directories=( - "challenge/logs", - "challenge/short_data", - "challenge/selected_reports", - ), - files=( - FileFixture("challenge/app.log", "log1"), - FileFixture("challenge/error.log", "log2"), - FileFixture("challenge/debug.log", "log3"), - FileFixture("challenge/notes.txt", "txt"), - FileFixture("challenge/data1.txt", "short1"), - FileFixture("challenge/data2.txt", "short2"), - FileFixture("challenge/data10.txt", "long"), - FileFixture("challenge/report_a.csv", "a\n"), - FileFixture("challenge/report_b.csv", "b\n"), - FileFixture("challenge/report_c.csv", "c\n"), - FileFixture("challenge/script.sh", "#!/bin/bash\n"), - ), - clean=("challenge",), - ) - completion = Completion( - answer=TupleAnswer( - ( - IntegerAnswer( - 3, - error_message="Počet zkopírovaných logů není správně. Zkontrolujte vzor s `*`.", - invalid_message="Všechny tři hodnoty musí být čísla.", - ), - IntegerAnswer( - 2, - error_message="Počet krátkých datových názvů není správně. Zkontrolujte vzor s `?`.", - invalid_message="Všechny tři hodnoty musí být čísla.", - ), - IntegerAnswer( - 2, - error_message="Počet vybraných reportů není správně. Zkontrolujte znakovou třídu.", - invalid_message="Všechny tři hodnoty musí být čísla.", - ), - ), - format_message="Formát: logy,data,reporty (tři čísla oddělená čárkou)", - ), - requirements=( - FileExists("challenge/logs/app.log"), - FileExists("challenge/logs/error.log"), - FileExists("challenge/logs/debug.log"), - FileExists("challenge/short_data/data1.txt"), - FileExists("challenge/short_data/data2.txt"), - FileExists("challenge/short_data/data10.txt", should_exist=False), - FileExists("challenge/selected_reports/report_a.csv"), - FileExists("challenge/selected_reports/report_b.csv"), - FileExists("challenge/selected_reports/report_c.csv", should_exist=False), - ), - ) - success_message = "Výborně! Dokončili jste Sekci 7. Wildcards jsou váš nejlepší přítel!" diff --git a/src/shellgame/levels/sections/section8.py b/src/shellgame/levels/sections/section8.py deleted file mode 100644 index 65e801c..0000000 --- a/src/shellgame/levels/sections/section8.py +++ /dev/null @@ -1,445 +0,0 @@ -from __future__ import annotations - -import stat - -from shellgame.levels.base import Level -from shellgame.levels.collector import Section -from shellgame.levels.completion import ( - AtDirectory, - ChoiceAnswer, - Completion, - DirectoryPermissionMode, - ExactAnswer, - PathsMatch, - PermissionBits, - PermissionMode, -) -from shellgame.levels.fixture import DirectoryFixture, FileFixture, WorkspaceFixture -from shellgame.levels.solution import Chdir, Chmod, RunShell, Solution - -section = Section(8, root="level-8") - - -@section.level(0) -class SectionIntroLevel(Level): - is_intro = True - title = "Sekce 8: Oprávnění" - instructions_file = "section8_intro.md" - hints = ["Přečtěte si úvod a pokračujte stisknutím Enter."] - success_message = "Jdeme na to!" - - -@section.level(1) -class FindExecutableLevel(Level): - title = "Hledání spustitelného souboru" - instructions = """ - Příkaz `ls -l` zobrazuje oprávnění v prvním sloupci (např. `-rw-r--r--`). - První trojice práv po znaku typu patří vlastníkovi souboru. - - ## Úkol: - V adresáři je několik skriptů. Najděte jediný, který má právo spuštění (`x`) - nastavené pro vlastníka. `x` u skupiny nebo ostatních nestačí. - - ## Příkazy: - - `ls -l`: Zobrazí oprávnění - - ## Odevzdání: - Odevzdejte název spustitelného souboru. - `shellgame submit <soubor>` - """ - hints = [ - "Podrobný výpis souborů včetně sloupců s oprávněními získáte přepínačem '-l'.", - "Spusťte 'ls -l' a rozdělte práva po znaku typu na trojice pro vlastníka, skupinu a ostatní.", - "Hledejte 'x' v první trojici (pozice vlastníka), ne v trojici skupiny nebo ostatních.", - ] - start_directory = "executables" - fixture = WorkspaceFixture( - files=( - FileFixture("executables/test.sh", "#!/bin/bash", mode=0o654), - FileFixture("executables/data.sh", "#!/bin/bash", mode=0o645), - FileFixture( - "executables/script.sh", - "#!/bin/bash\necho Hi", - mode=0o744, - ), - ) - ) - completion = Completion( - answer=ExactAnswer( - "script.sh", - error_message=( - "Tenhle soubor nemá 'x' v první trojici. Ve výpisu 'ls -l' čtěte práva po znaku typu " - "po trojicích: vlastník, skupina, ostatní. 'x' u skupiny nebo ostatních se nepočítá." - ), - ) - ) - success_message = "Správně! Práva se čtou po trojicích a rozhoduje ta, která odpovídá vaší roli." - - -@section.level(2) -class MakeExecutableLevel(Level): - solution = Solution(steps=(RunShell("chmod u+x run_me.sh"),), answer="run_me.sh") - title = "Nastavení spustitelnosti" - instructions = """ - Aby šel skript spustit (např. `./script.sh`), musí mít nastavené právo `x`. - Příkaz `chmod` (change mode) mění oprávnění. - - ### Proč je to důležité? - Když napíšete skript nebo stáhnete program, často není automaticky spustitelný. - Musíte mu explicitně dát právo ke spuštění - je to bezpečnostní opatření. - - ### Běžný pracovní postup - 1. Napíšete skript: `nano muj_skript.sh` - 2. Pokusíte se spustit: `./muj_skript.sh` → "Permission denied" - 3. Přidáte právo: `chmod u+x muj_skript.sh` - 4. Nyní funguje: `./muj_skript.sh` ✓ - - ## Úkol: - Soubor `run_me.sh` nejde spustit. Přidejte mu právo pro spuštění pro vlastníka (`u`). - - ## Příkazy: - - `chmod u+x <soubor>`: Přidá právo execute pro usera - - ## Odevzdání: - Po přidání práva spuštění spusťte: - `shellgame submit` - (můžete také zadat: `shellgame submit run_me.sh`) - """ - hints = [ - "Právo 'x' (execute) je potřeba pro spuštění. Jak ho přidáte pro vlastníka (user)?", - "Syntaxe chmod: chmod kdo+co soubor. 'u' = user, 'x' = execute.", - "Použijte 'chmod u+x run_me.sh'.", - ] - start_directory = "permissions" - fixture = WorkspaceFixture(files=(FileFixture("permissions/run_me.sh", "#!/bin/bash\necho Run me", mode=0o644),)) - completion = Completion( - answer=ExactAnswer("run_me.sh"), - requirements=( - PermissionBits( - "permissions/run_me.sh", - required=stat.S_IXUSR, - error_message=( - "Vlastník stále nemá právo 'x'. Zkontrolujte 'ls -l run_me.sh': rozhoduje první trojice " - "po znaku typu, ne trojice skupiny ani ostatních." - ), - ), - ), - allow_empty=True, - ) - success_message = "Správně! Symbolický chmod se skládá ze tří částí: kdo, operace a které právo." - - -@section.level(3) -class MakeReadOnlyLevel(Level): - solution = Solution(steps=(RunShell("chmod a-w config.readonly"),), answer=None) - title = "Odebrání práva zápisu" - instructions = """ - Někdy chcete zabránit nechtěnému přepsání souboru. Můžete mu odebrat právo pro zápis (`w`). - - ## Úkol: - Odeberte souboru `config.readonly` právo zápisu pro všechny: vlastníka, skupinu i ostatní. - - ## Příkazy: - - `chmod a-w <soubor>`: Odeberte write pro all (všechny) - - Písmeno `a` nevynechávejte: bez něj výsledek ovlivňuje výchozí maska práv (`umask`). - - ## Odevzdání: - Po odebrání všech práv zápisu spusťte: - `shellgame submit` - """ - hints = [ - "'a' znamená all (všechny: vlastníka, skupinu i ostatní), '-w' odebírá právo zápisu.", - "Tvar je 'chmod <kdo><operace><právo> <soubor>': tady je kdo = všichni, operace = odebrat a právo = zápis.", - "Spusťte 'chmod a-w config.readonly'.", - ] - start_directory = "permissions" - fixture = WorkspaceFixture(files=(FileFixture("permissions/config.readonly", "Do not touch", mode=0o644),)) - completion = Completion( - requirements=( - PermissionBits( - "permissions/config.readonly", - forbidden=stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH, - error_message=( - "Někde právo zápisu zůstalo. V 'ls -l config.readonly' projděte všechny tři trojice po řadě: " - "'w' nesmí být ani u vlastníka, ani u skupiny, ani u ostatních." - ), - ), - ), - ) - success_message = "Správně! Symbolický zápis mění právo jen vybraným trojicím a zbytek nechává být." - - -@section.level(4) -class NumericPermissionsLevel(Level): - solution = Solution(steps=(RunShell("chmod 755 deploy.sh"),), answer=None) - title = "Číselný zápis" - instructions = """ - Oprávnění lze nastavit i číselně (oktalově). - - 4 = read (r) - - 2 = write (w) - - 1 = execute (x) - - Součet dává kombinaci (např. 7 = 4+2+1 = rwx, 5 = 4+1 = r-x). - Zadávají se tři čísla: pro vlastníka, skupinu a ostatní. - Např. `755` znamená `rwx` pro vlastníka, `r-x` pro skupinu, `r-x` pro ostatní. - - ## Úkol: - Nastavte spouštěcímu skriptu `deploy.sh` oprávnění `755` (`rwxr-xr-x`). - - ## Příkazy: - - `chmod 755 <soubor>` - - ## Odevzdání: - Po nastavení přesného režimu spusťte: - `shellgame submit` - """ - hints = [ - "Oktalový zápis: 7 = 4+2+1 (rwx) pro vlastníka, 5 = 4+1 (r-x) pro skupinu a ostatní.", - "Tvar je 'chmod <tři číslice> <soubor>': každá číslice patří jedné trojici v pořadí " - "vlastník, skupina, ostatní a je součtem 4 (r), 2 (w) a 1 (x).", - "Spusťte 'chmod 755 deploy.sh' a výsledek zkontrolujte pomocí 'ls -l deploy.sh'.", - ] - start_directory = "permissions" - fixture = WorkspaceFixture( - files=(FileFixture("permissions/deploy.sh", "#!/bin/sh\necho 'Nasazuji aplikaci'\n", mode=0o600),) - ) - completion = Completion( - requirements=( - PermissionMode( - "permissions/deploy.sh", - 0o755, - error_message=( - "Režim zatím nesedí. Přečtěte si aktuální stav příkazem 'ls -l deploy.sh' a přeložte " - "každou trojici zpět na číslici (r=4, w=2, x=1, '-'=0). " - "Nejčastější chybou je právo zápisu navíc u skupiny nebo ostatních." - ), - ), - ) - ) - success_message = "Správně! Číselný režim nastavuje všechny tři trojice najednou na přesnou hodnotu." - - -@section.level(5) -class PermissionsChallengeLevel(Level): - solution = Solution( - steps=(RunShell("chmod u+x script.sh && chmod a-w secret.txt && chmod 644 shared.txt && chmod g+r team.txt"),), - answer=None, - ) - title = "Kontrolní úkol: Oprávnění souborů" - instructions = """ - ### Kontrolní úkol: Oprávnění souborů - - Ukažte, že rozumíte oprávněním běžných souborů předtím, než přejdeme k adresářům! - - ### Úkol - V `level-8/challenge` jsou 4 soubory: - - 1. `script.sh` - potřebuje být **spustitelný** vlastníkem - 2. `secret.txt` - odeberte **právo zápisu všem** - 3. `shared.txt` - nastavte oprávnění **644** (rw-r--r--) - 4. `team.txt` - přidejte skupině právo čtení, ostatní práva neměňte - - ### Shrnutí příkazů Sekce 8 - ``` - ls -l → Zobrazí oprávnění - chmod u+x soubor → Přidá vlastníkovi právo spuštění - chmod a-w soubor → Odebere všem právo zápisu - chmod g+r soubor → Přidá skupině právo čtení - chmod 755 soubor → Nastaví rwxr-xr-x - chmod 644 soubor → Nastaví rw-r--r-- - ``` - - ### Oktalové oprávnění - ``` - 4 = read 2 = write 1 = execute - 7 = rwx 6 = rw- 5 = r-x 4 = r-- - ``` - - ### Odevzdání - Po splnění všech bodů spusťte `shellgame submit`. - """ - hints = [ - "U každého souboru určete skupinu uživatelů (u, g, o nebo a), operaci (+, - nebo =) a požadované právo.", - "První, druhý a čtvrtý úkol lze vyřešit symbolicky; u shared.txt nastavte přesný číselný režim.", - "Použijte 'chmod u+x script.sh', 'chmod a-w secret.txt', 'chmod 644 shared.txt' a 'chmod g+r team.txt'.", - ] - start_directory = "challenge" - fixture = WorkspaceFixture( - files=( - FileFixture( - "challenge/script.sh", - "#!/bin/bash\necho 'Hello'\n", - mode=0o600, - ), - FileFixture("challenge/secret.txt", "Top secret!\n", mode=0o666), - FileFixture("challenge/shared.txt", "Shared content\n", mode=0o777), - FileFixture("challenge/team.txt", "Team notes\n", mode=0o600), - ), - clean=("challenge",), - ) - completion = Completion( - requirements=( - PermissionBits( - "challenge/script.sh", - required=stat.S_IXUSR, - error_message="Vlastník stále nemůže spustit script.sh.", - ), - PermissionBits( - "challenge/secret.txt", - forbidden=stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH, - error_message="secret.txt má stále alespoň jedno právo zápisu.", - ), - PermissionMode( - "challenge/shared.txt", - 0o644, - error_message="shared.txt nemá přesně oprávnění rw-r--r--.", - ), - PermissionMode( - "challenge/team.txt", - 0o640, - error_message="team.txt nemá právo čtení pro skupinu při zachování ostatních práv.", - ), - ), - ) - success_message = "Správně! Oprávnění běžných souborů máte pod kontrolou. Nyní se podíváme na adresáře." - - -@section.level(6) -class DirectoryWritePermissionLevel(Level): - solution = Solution( - steps=( - RunShell("cp locked/report.txt recovered.txt"), - Chmod("directory-write/locked", 0o750), - RunShell("cp source.txt locked/"), - ), - answer="adresář", - ) - title = "Kdo dovoluje zápis do adresáře?" - instructions = """ - ### Cíl - Prakticky ověřte, že vytvoření nové položky řídí oprávnění **adresáře**, - ne oprávnění kopírovaného souboru. - - Adresář `locked` má režim `550`: lze ho číst a procházet, ale nelze do něj zapisovat. - - ### Předpověď a pozorování - **Nejdřív předpověď:** než cokoli spustíte, řekněte nahlas nebo si zapište, zda `cp source.txt locked/` - projde, nebo selže - a proč. Chybová hláška není překážka, ale hlavní výsledek pokusu; přečtěte si ji celou. - - 1. Spusťte `cp source.txt locked/` a porovnejte hlášku se svou předpovědí. - 2. Ověřte, že čtení ven funguje: `cp locked/report.txt recovered.txt`. - 3. Přidejte vlastníkovi adresáře právo zápisu: `chmod u+w locked`. - 4. Zopakujte `cp source.txt locked/`; tentokrát musí uspět. - - ### Vysvětlení - Co rozhodovalo o možnosti vytvořit `locked/source.txt` — oprávnění - zdrojového **souboru**, nebo cílového **adresáře**? - - ### Odevzdání - `shellgame submit adresář` - """ - hints = [ - "Právo 'w' na soubor řídí změnu jeho obsahu. Vytvoření nového jména je operace nad adresářem.", - "Režim 550 nedává vlastníkovi adresáře právo 'w'; příkaz 'chmod u+w locked' ho přidá.", - "Zkopírujte report ven, přidejte 'w' adresáři locked, zopakujte kopii dovnitř a odevzdejte 'adresář'.", - ] - start_directory = "directory-write" - fixture = WorkspaceFixture( - clean=("directory-write",), - directory_fixtures=(DirectoryFixture("directory-write/locked", mode=0o550),), - files=( - FileFixture("directory-write/source.txt", "nová data\n"), - FileFixture("directory-write/locked/report.txt", "existující zpráva\n"), - ), - ) - completion = Completion( - answer=ChoiceAnswer( - ("adresář", "adresar", "directory"), - case_sensitive=False, - error_message="Zaměřte se na objekt, ve kterém vzniká nové jméno souboru.", - required_message="Odevzdejte odpověď: shellgame submit <soubor|adresář>", - ), - requirements=( - PathsMatch( - "directory-write/locked/report.txt", - "directory-write/recovered.txt", - destination_error="Nejdřív zkopírujte existující report z locked ven.", - ), - PathsMatch( - "directory-write/source.txt", - "directory-write/locked/source.txt", - destination_error="Po přidání práva zápisu zkopírujte source.txt do locked/.", - ), - DirectoryPermissionMode( - "directory-write/locked", - 0o750, - error_message="Adresář locked nemá očekávaná práva po příkazu 'chmod u+w locked'.", - ), - ), - ) - success_message = "Správně! Vytváření a mazání jmen řídí zapisovatelnost nadřazeného adresáře." - - -@section.level(7) -class DirectoryTraversePermissionLevel(Level): - solution = Solution( - steps=( - Chmod("directory-traverse/parent", 0o644), - Chmod("directory-traverse/parent", 0o744), - Chdir("directory-traverse/parent"), - ), - answer="x", - ) - title = "Právo x u adresáře" - instructions = """ - ### Cíl - Zažijte rozdíl mezi čtením názvů a **průchodem** adresářem. - - Začínáte v `parent/child`. Nadřazený adresář má běžná práva `755`. - - ### Předpověď a pozorování - **Nejdřív předpověď:** po `chmod a-x ..` si zapište nebo řekněte nahlas, co udělá `cd ..` - a proč. - Hlášku, kterou shell vypíše, čtěte pozorně: právě ona je odpovědí, ne překážkou. - - 1. Odeberte všem právo průchodu nadřazeným adresářem: `chmod a-x ..`. - 2. Zkuste `cd ..` a porovnejte výsledek se svou předpovědí. - 3. Vraťte vlastníkovi průchod: `chmod u+x ..`. - 4. Zopakujte `cd ..`; nyní musí uspět. - - ### Vysvětlení - Které písmeno oprávnění umožňuje vstoupit do adresáře a procházet přes něj? - - ### Odevzdání - Z adresáře `parent` spusťte `shellgame submit x`. - """ - hints = [ - "U adresáře neznamená 'x' spuštění programu, ale možnost průchodu cestou.", - "Po 'chmod a-x ..' nadřazený adresář stále může mít 'r', ale cesta přes něj nefunguje.", - "Obnovte vlastníkovi průchod příkazem 'chmod u+x ..', přejděte do parent a odevzdejte 'x'.", - ] - start_directory = "directory-traverse/parent/child" - fixture = WorkspaceFixture( - clean=("directory-traverse",), - directory_fixtures=( - DirectoryFixture("directory-traverse/parent", mode=0o755), - DirectoryFixture("directory-traverse/parent/child", mode=0o755), - ), - ) - completion = Completion( - answer=ChoiceAnswer( - ("x", "execute", "průchod", "pruchod"), - case_sensitive=False, - error_message="Hledejte právo, které u adresáře znamená průchod cestou.", - required_message="Odevzdejte označení práva: shellgame submit <písmeno>", - ), - requirements=( - AtDirectory("directory-traverse/parent"), - DirectoryPermissionMode( - "directory-traverse/parent", - 0o744, - error_message="Obnovte právo průchodu pouze vlastníkovi nadřazeného adresáře.", - ), - ), - ) - success_message = "Výborně! Dokončili jste Sekci 8 a rozumíte právům souborů i adresářů." diff --git a/src/shellgame/levels/sections/section9.py b/src/shellgame/levels/sections/section9.py deleted file mode 100644 index 96002cf..0000000 --- a/src/shellgame/levels/sections/section9.py +++ /dev/null @@ -1,634 +0,0 @@ -from __future__ import annotations - -from shellgame.levels.base import Level -from shellgame.levels.collector import Section -from shellgame.levels.completion import ( - Completion, - ExactAnswer, - IntegerAnswer, - TextFileContent, - TupleAnswer, -) -from shellgame.levels.fixture import FileFixture, WorkspaceFixture -from shellgame.levels.solution import RunShell, Solution - -section = Section(9, root="level-9") - - -_ACCESS_LOG = """2024-01-01 10:00:00 INFO Server started -2024-01-01 10:05:23 ERROR Connection refused -2024-01-01 10:10:45 INFO User logged in -2024-01-01 10:15:00 WARNING Low memory -2024-01-01 10:20:12 ERROR Database timeout -2024-01-01 10:25:00 INFO Request processed -2024-01-01 10:30:33 ERROR File not found -2024-01-01 10:35:00 INFO Cache cleared -2024-01-01 10:40:55 ERROR Permission denied -2024-01-01 10:45:00 DEBUG Verbose output -2024-01-01 10:50:18 ERROR Network unreachable -2024-01-01 10:55:00 INFO Backup completed -2024-01-01 11:00:00 ERROR Disk full -2024-01-01 11:05:00 INFO Server shutdown -2024-01-01 11:10:42 ERROR Service unavailable -""" -_LONG_FILE = "\n".join( - [ - "START of the file - this is line 1", - *(f"Line number {index} with some content" for index in range(2, 50)), - "END of the file - this is line 50", - "", - ] -) -_ARTICLE = """Linux je svobodný operační systém. -Byl vytvořen Linusem Torvaldsem v roce 1991. -Dnes pohání většinu serverů na internetu. -Je základem systému Android a mnoha dalších. -Open source komunita ho neustále vylepšuje. -""" -_VISITORS = """Alice -Bob -Charlie -Alice -David -Bob -Eve -Alice -""" - - -@section.level(0) -class SectionIntro(Level): - is_intro = True - title = "Sekce 9: Vstup, výstup a stav příkazů" - instructions_file = "section9_intro.md" - hints = ["Přečtěte si úvod a pokračujte stisknutím Enter."] - success_message = "Jdeme na to!" - - -@section.level(1) -class RedirectLsToFileLevel(Level): - solution = Solution(steps=(RunShell("ls > seznam.txt"),), answer="seznam.txt") - title = "Uložení výstupu" - instructions = """\ - # Uložení výstupu - - Operátor `>` přesměruje výstup příkazu do souboru. Pokud soubor neexistuje, vytvoří se. - Pokud existuje, **přepíše se**. - - ## Úkol - Uložte seznam souborů v aktuálním adresáři (výstup `ls`) do souboru `seznam.txt`. - - ## Příkazy - - `ls > seznam.txt` - - ## Odevzdání - Po vytvoření souboru spusťte: - `shellgame submit` - (můžete také zadat: `shellgame submit seznam.txt`) - """ - hints = [ - "Použijte operátor '>' pro přesměrování výstupu.", - "Příkaz 'ls' vypíše obsah adresáře.", - "Zkuste: 'ls > seznam.txt'.", - ] - start_directory = "redirection" - fixture = WorkspaceFixture( - files=( - FileFixture("redirection/file1"), - FileFixture("redirection/file2"), - ), - clean=("redirection/seznam.txt",), - ) - completion = Completion( - answer=ExactAnswer("seznam.txt"), - requirements=( - TextFileContent( - "redirection/seznam.txt", - contains=("file1", "file2"), - error_message="Soubor neobsahuje očekávaný výstup příkazu ls.", - missing_message="Soubor neexistuje.", - ), - ), - allow_empty=True, - ) - success_message = "Správně! Výstup příkazu nemusí skončit na obrazovce — dá se uložit a dál s ním pracovat." - - -@section.level(2) -class AppendWithRedirectLevel(Level): - solution = Solution(steps=(RunShell("echo 'Konec logu' >> log.txt"),), answer="log.txt") - title = "Přidání na konec" - instructions = """\ - # Přidání na konec - - Operátor `>>` (append) přidá výstup na konec souboru, aniž by smazal původní obsah. - - ## Úkol - Máte soubor `log.txt` s nějakým obsahem. Přidejte na jeho konec text "Konec logu" - pomocí příkazu `echo`. - - ## Příkazy - - `echo "Text" >> soubor` - - ## Odevzdání - Po přidání textu spusťte: - `shellgame submit` - (můžete také zadat: `shellgame submit log.txt`) - """ - hints = [ - "Dvě šipky '>>' znamenají append (připojení na konec souboru bez přepsání obsahu).", - "Spusťte 'echo \"Konec logu\" >> log.txt'.", - ] - start_directory = "redirection" - fixture = WorkspaceFixture(files=(FileFixture("redirection/log.txt", "Start logu\nZaznam 1\n"),)) - completion = Completion( - answer=ExactAnswer("log.txt"), - requirements=( - TextFileContent( - "redirection/log.txt", - exact="Start logu\nZaznam 1\nKonec logu\n", - error_message=( - "Soubor musí zachovat původní obsah a přidat nový řádek přesně na konec. " - "Zmizely-li původní řádky, použili jste jednoduchou šipku `>`, která soubor přepíše; " - "připojení zajistí až zdvojená šipka. Původní stav vrátí `shellgame reset`." - ), - missing_message="Soubor neexistuje.", - ), - ), - allow_empty=True, - ) - success_message = "Správně! Zdvojená šipka připojuje, takže předchozí obsah souboru zůstane zachovaný." - - -@section.level(3) -class ConcatenatePartsLevel(Level): - solution = Solution(steps=(RunShell("cat part1.txt part2.txt > full.txt"),), answer="full.txt") - title = "Spojování souborů" - instructions = """\ - # Spojování souborů - - Příkaz `cat` (concatenate) umí vypsat obsah více souborů za sebou. - Když to zkombinujete s přesměrováním, můžete spojit více souborů do jednoho. - - ## Úkol - Spojte obsah souborů `part1.txt` a `part2.txt` do nového souboru `full.txt`. - - ## Příkazy - - `cat soubor1 soubor2 > novy_soubor` - - ## Odevzdání - Po vytvoření spojeného souboru spusťte: - `shellgame submit` - (můžete také zadat: `shellgame submit full.txt`) - """ - hints = [ - "Příkaz 'cat' umí přijmout více souborů najednou a vypsat jejich obsahy za sebou.", - "Výstup více souborů z 'cat' můžete přesměrovat pomocí '>' do cílového souboru.", - "Spusťte 'cat part1.txt part2.txt > full.txt'. Pořadí argumentů určuje pořadí v souboru.", - ] - start_directory = "concat" - fixture = WorkspaceFixture( - files=( - FileFixture("concat/part1.txt", "First part.\n"), - FileFixture("concat/part2.txt", "Second part.\n"), - ), - clean=("concat/full.txt",), - ) - completion = Completion( - answer=ExactAnswer("full.txt"), - requirements=( - TextFileContent( - "concat/full.txt", - exact="First part.\nSecond part.\n", - error_message=( - "Soubor musí obsahovat obě části přesně v zadaném pořadí. " - "Jsou-li obsahy na místě, ale prohozené, prohodili jste argumenty příkazu cat: " - "jejich pořadí určuje pořadí řádků ve výstupu." - ), - missing_message="Soubor neexistuje.", - ), - ), - allow_empty=True, - ) - success_message = "Správně! Příkaz cat čte soubory v pořadí argumentů a stejné pořadí má i výsledek." - - -@section.level(4) -class EchoCreateFileLevel(Level): - solution = Solution( - steps=(RunShell('echo "Ahoj svete" > "muj pozdrav.txt"'),), - answer="muj pozdrav.txt", - ) - title = "Uvozovky v textu i názvu" - instructions = """\ - # Uvozovky v textu i názvu souboru - - Shell dělí příkaz podle mezer. Uvozovky proto chrání víceslovný text i název - souboru s mezerami — každou část uzavřete zvlášť. - - ## Úkol - Pomocí `echo` vytvořte soubor `muj pozdrav.txt` s jediným řádkem `Ahoj svete`. - - ## Příkaz - - `echo "Ahoj svete" > "muj pozdrav.txt"` - - První dvojice uvozovek chrání text, druhá název výstupního souboru. - - ## Odevzdání - Uvozovky potřebuje i název předaný příkazu `shellgame`: - `shellgame submit "muj pozdrav.txt"` - """ - hints = [ - "Mezery oddělují argumenty. Uvozovky udrží více slov pohromadě jako jeden text nebo jednu cestu.", - "Uzavřete do uvozovek text za `echo` a zvlášť také název za `>`.", - 'Spusťte `echo "Ahoj svete" > "muj pozdrav.txt"` a název v uvozovkách také odevzdejte.', - ] - start_directory = "echo" - fixture = WorkspaceFixture( - clean=("echo/pozdrav.txt", "echo/muj pozdrav.txt"), - directories=("echo",), - ) - completion = Completion( - answer=ExactAnswer( - "muj pozdrav.txt", - required_message='Odevzdejte název v uvozovkách: shellgame submit "muj pozdrav.txt"', - ), - requirements=( - TextFileContent( - "echo/muj pozdrav.txt", - exact="Ahoj svete\n", - error_message="Soubor nemá přesně požadovaný jeden řádek.", - missing_message="Chybí soubor s požadovaným názvem obsahujícím mezeru.", - ), - ), - ) - success_message = "Správně! Uvozovky drží pohromadě to, co by shell jinak rozdělil podle mezer." - - -@section.level(5) -class PipeGrepAndCountLevel(Level): - title = "Propojení příkazů (Pipes)" - instructions = """\ - # Propojení příkazů pomocí rour (pipes) - - Znak `|` (pipe/roura) pošle výstup jednoho příkazu jako vstup druhému. - - ## Stavební kameny - - `grep "vzor" soubor` vypíše jen ty řádky souboru, které vzor obsahují. - Vyhledávání se naplno věnuje Sekce 11; tady `grep` použijeme jako hotový filtr. - - `wc -l` spočítá řádky, které dostane na vstupu. - - `|` pošle výstup levého příkazu na vstup pravého. - - ## Úkol - V aktuálním adresáři je soubor `access.log` s mnoha řádky. - Spočítejte, kolik řádků obsahuje slovo "ERROR". Příkaz si složte sami ze tří dílů výše. - - ## Odevzdání - Odevzdejte nalezený počet (číslo). - `shellgame submit <číslo>` - """ - hints = [ - "Pipe (|) propojuje výstup prvního příkazu se vstupem druhého.", - "grep najde řádky s 'ERROR', wc -l je spočítá. Spojte je pomocí |.", - 'Použijte: grep "ERROR" access.log | wc -l', - ] - start_directory = "pipes" - fixture = WorkspaceFixture(files=(FileFixture("pipes/access.log", _ACCESS_LOG),)) - completion = Completion( - answer=IntegerAnswer( - 7, - mistakes={ - 15: "Spočítali jste všechny řádky. Potřebujete jen ty s 'ERROR'. Použijte grep před wc.", - }, - ) - ) - success_message = "Správně! Roura spojí jednoduché příkazy v nástroj, jakým žádný z nich sám o sobě není." - - -@section.level(6) -class HeadTailFirstAndLastWordLevel(Level): - title = "Začátek a konec souboru" - instructions = """\ - # Head a Tail - prohlížení částí souboru - - ## Úkol - V souboru `long_file.txt` je 50 řádků. - 1. Zjistěte první slovo na 1. řádku (pomocí `head -n 1`) - 2. Zjistěte první slovo na posledním řádku (pomocí `tail -n 1`) - - ## Odevzdání - Odevzdejte obě slova oddělená čárkou: `první,poslední` - `shellgame submit <první>,<poslední>` - """ - hints = [ - "head -n 1 zobrazí první řádek, tail -n 1 zobrazí poslední.", - "Odevzdejte první slovo z každého z těchto dvou řádků.", - "Formát odpovědi je `prvni,posledni` - dvě slova oddělená čárkou, bez mezery.", - ] - start_directory = "headtail" - success_message = "Správně! Head a tail jsou skvělé pro rychlý náhled do souborů." - fixture = WorkspaceFixture(files=(FileFixture("headtail/long_file.txt", _LONG_FILE),)) - completion = Completion( - answer=TupleAnswer( - ( - ExactAnswer( - "START", - error_message="První slovo není správně. Použijte 'head -n 1 long_file.txt'.", - ), - ExactAnswer( - "END", - error_message="Poslední slovo není správně. Použijte 'tail -n 1 long_file.txt'.", - ), - ), - format_message="Formát: první_slovo,poslední_slovo (např. AHOJ,SVET)", - required_message="Zadejte odpověď ve formátu: první_slovo,poslední_slovo", - ) - ) - - -@section.level(7) -class WordAndLineCountLevel(Level): - title = "Počítání (wc)" - instructions = """\ - # Příkaz wc (word count) - - ## Úkol - Zjistěte o souboru `article.txt`: - 1. Kolik má řádků? (`wc -l`) - 2. Kolik má slov? (`wc -w`) - - ## Odevzdání - Odevzdejte: `řádky,slova` (např. `10,50`) - `shellgame submit <řádky>,<slova>` - """ - hints = [ - "wc -l počítá řádky, wc -w počítá slova.", - "Spusťte oba příkazy na `article.txt` a zapište si obě čísla.", - "Odevzdejte je v pořadí řádky,slova — bez mezery za čárkou.", - ] - start_directory = "wc" - success_message = "Správně! Příkaz wc je nepostradatelný pro rychlou analýzu souborů." - fixture = WorkspaceFixture(files=(FileFixture("wc/article.txt", _ARTICLE),)) - completion = Completion( - answer=TupleAnswer( - ( - IntegerAnswer( - 5, - mistakes={ - 31: ( - "První číslo má být počet řádků, ne slov — vypadá to, že máte hodnoty prohozené. " - "Pořadí je řádky,slova." - ), - }, - error_message="Počet řádků není správně. Použijte 'wc -l article.txt'.", - invalid_message="Obě hodnoty musí být čísla.", - ), - IntegerAnswer( - 31, - mistakes={ - 5: "Druhé číslo má být počet slov, ne řádků. Ten už jste zapsali jako první hodnotu.", - }, - error_message="Počet slov není správně. Použijte 'wc -w article.txt'.", - invalid_message="Obě hodnoty musí být čísla.", - ), - ), - format_message="Formát: řádky,slova (např. 10,50) — na pořadí obou čísel záleží.", - required_message="Zadejte odpověď ve formátu: řádky,slova", - ) - ) - - -@section.level(8) -class SortUniqCountUniqueLevel(Level): - title = "Řazení a odstranění duplicit" - instructions = """\ - # Sort a Uniq - řazení a deduplikace - - Příkazy `sort` a `uniq` jsou mocné nástroje pro zpracování textových dat. - - ## Stavební kameny - - `sort soubor` vypíše řádky seřazeně, takže stejné hodnoty skončí vedle sebe. - - `uniq` zahodí opakující se řádky, ale pozná jen ty **sousední**. - - `wc -l` spočítá řádky, které dostane na vstupu. - - Pořadí proto není libovolné: rozmyslete si, co musí `uniq` dostat na vstup, aby fungoval. - - ## Úkol - V souboru `visitors.txt` jsou jména návštěvníků (někteří přišli vícekrát). - Zjistěte, kolik je UNIKÁTNÍCH návštěvníků. Příkazy si pospojujte rourami sami. - - ## Odevzdání - Odevzdejte počet unikátních návštěvníků. - `shellgame submit <číslo>` - """ - hints = [ - "Příkaz uniq odstraní duplikáty, ale jen sousedící! Proto nejdřív sort.", - "Řetězec: sort → uniq → wc -l spočítá unikátní řádky.", - "Spusťte 'sort visitors.txt | uniq | wc -l' a odevzdejte číslo z výstupu.", - ] - start_directory = "sort" - success_message = "Správně! Sort | uniq je klasická kombinace pro práci s daty." - fixture = WorkspaceFixture(files=(FileFixture("sort/visitors.txt", _VISITORS),)) - completion = Completion( - answer=IntegerAnswer( - 5, - mistakes={ - 8: "Spočítali jste všechny řádky, ne unikátní. Zkuste: sort visitors.txt | uniq | wc -l", - 3: "Možná jste spočítali jen duplikáty. Hledáme počet unikátních jmen.", - }, - ) - ) - - -@section.level(9) -class SectionSummaryChallengeLevel(Level): - solution = Solution( - steps=( - RunShell('echo "Hello World" > message.txt'), - RunShell('echo "Goodbye" >> message.txt'), - RunShell("ls | wc -l"), - ), - answer="3", - ) - title = "Výzva: Přesměrování a roury" - instructions = """\ - ### Výzva: Přesměrování a roury - - ### Úkol - V aktuálním adresáři: - - 1. Vytvořte `message.txt` s prvním řádkem `Hello World`. - 2. Přidejte na konec druhý řádek `Goodbye`, aniž by první zmizel. - 3. Propojte výpis obsahu adresáře s počítáním řádků a zjistěte počet položek. - - Vystačíte si s `echo`, `>`, `>>`, `ls`, `|` a `wc -l`. - Odevzdejte zjištěný počet položek. - - ### Odevzdání - `shellgame submit <počet>` - """ - hints = [ - "První přesměrování má soubor vytvořit, druhé musí zachovat jeho obsah. " - "Výpis pak pošlete rourou do počítadla řádků.", - 'Soubor vytvoříte pomocí `echo "Hello World" > message.txt` a druhý řádek přidáte přes `>>`.', - "Počet položek zjistíte příkazem `ls | wc -l`.", - ] - start_directory = "challenge" - fixture = WorkspaceFixture( - files=( - FileFixture("challenge/sample1.txt", "sample"), - FileFixture("challenge/sample2.txt", "sample"), - ), - clean=("challenge",), - ) - completion = Completion( - answer=IntegerAnswer( - 3, - mistakes={ - 2: "Spočítali jste jen sample1.txt a sample2.txt. Vytvořili jste message.txt?", - }, - ), - requirements=( - TextFileContent( - "challenge/message.txt", - exact="Hello World\nGoodbye", - strip=True, - error_message=("message.txt musí obsahovat řádky 'Hello World' a 'Goodbye' v tomto pořadí."), - missing_message=("Chybí message.txt. Vytvořte pomocí 'echo \"Hello World\" > message.txt'."), - ), - ), - ) - success_message = "Skvělé! Přesměrování i roury máte v malíku!" - - -@section.level(10) -class InteractiveCatInputLevel(Level): - solution = Solution( - steps=(RunShell("printf '%s\\n' 'První řádek' 'Druhý řádek' | cat > poznamka.txt"),), - answer="poznamka.txt", - ) - title = "Interaktivní vstup a EOF" - instructions = """\ - # Interaktivní vstup a EOF - - Když spustíte `cat > soubor`, příkaz čte řádky z klávesnice a zapisuje je do souboru. - Na prázdném řádku stiskněte **Ctrl+D**: terminál tím oznámí EOF (konec vstupu) a `cat` - řádně skončí. **Ctrl+C** místo toho běžící příkaz přeruší (interrupt). - - ## Úkol - Spusťte `cat > poznamka.txt` a zadejte přesně tyto dva řádky: - - ```text - První řádek - Druhý řádek - ``` - - Po druhém řádku stiskněte Enter a potom na prázdném řádku Ctrl+D. - - ## Odevzdání - Po ukončení zápisu pomocí Ctrl+D spusťte: - `shellgame submit` - (můžete také zadat: `shellgame submit poznamka.txt`) - """ - hints = [ - "`cat` bez názvu vstupního souboru čte standardní vstup; EOF mu oznámí, že už žádná data nepřijdou.", - "Po `cat > poznamka.txt` napište oba řádky. Ctrl+D použijte až na novém prázdném řádku.", - "Jestli jste použili Ctrl+C nebo udělali překlep, spusťte `shellgame reset` a zopakujte zápis s Ctrl+D.", - ] - start_directory = "stdin" - fixture = WorkspaceFixture( - directories=("stdin",), - clean=("stdin/poznamka.txt",), - ) - completion = Completion( - answer=ExactAnswer( - "poznamka.txt", - required_message="Odevzdejte název souboru: shellgame submit poznamka.txt", - ), - requirements=( - TextFileContent( - "stdin/poznamka.txt", - exact="První řádek\nDruhý řádek\n", - error_message=( - "Soubor neobsahuje přesně oba zadané řádky. Častá příčina je Ctrl+C: " - "to příkaz přeruší uprostřed práce, takže text zůstane useknutý nebo vůbec nedojde na disk. " - "Vstup řádně ukončí až Ctrl+D na prázdném řádku. Začněte znovu po `shellgame reset`." - ), - missing_message=( - "Soubor chybí. Začněte příkazem `cat > poznamka.txt`; pokud jste ho přerušili pomocí Ctrl+C, " - "nemusel vzniknout vůbec." - ), - ), - ), - allow_empty=True, - ) - success_message = "Správně! EOF ukončilo vstup a `cat` soubor uzavřel." - - -@section.level(11) -class CommandStatusLevel(Level): - solution = Solution( - steps=( - RunShell('true && echo "stav: uspech" > status.txt'), - RunShell('false || echo "stav: neuspech" >> status.txt'), - ), - answer="status.txt", - ) - title = "Návratový kód: && a ||" - instructions = """\ - # Návratový kód: `&&` a `||` - - Každý příkaz skončí návratovým kódem (exit code): **0 znamená úspěch**, nenulová - hodnota neúspěch. V interaktivním Bash i Fish podle něj můžete spojovat příkazy: - - - `první && druhý` spustí druhý jen po úspěchu prvního, - - `první || druhý` spustí druhý jen po neúspěchu prvního. - - Příkazy `true` a `false` vracejí právě stav 0 a nenulový stav. - - ## Úkol - Spusťte postupně oba řetězce; zapíšou do souboru `status.txt` dva řádky: - - ```bash - true && echo "stav: uspech" ___ status.txt - false || echo "stav: neuspech" ___ status.txt - ``` - - Na místo `___` doplňte přesměrování `>` nebo `>>`. Rozhodněte se podle toho, - že první řádek soubor zakládá a druhý se musí přidat za něj — výsledek má mít - právě dva řádky v tomto pořadí. - - ## Odevzdání - `shellgame submit status.txt` - """ - hints = [ - "Návratový kód 0 značí úspěch, nenulový kód neúspěch. Operátory sledují právě tento stav.", - ( - "Za `&&` pokračuje úspěšný příkaz; za `||` pokračuje neúspěšný. " - "První řetězec soubor zakládá (stačí přepsání), druhý k němu jen přidává." - ), - 'Spusťte `true && echo "stav: uspech" > status.txt` a potom `false || echo "stav: neuspech" >> status.txt`.', - ] - start_directory = "status" - fixture = WorkspaceFixture( - directories=("status",), - clean=("status/status.txt",), - ) - completion = Completion( - answer=ExactAnswer( - "status.txt", - required_message="Odevzdejte název souboru: shellgame submit status.txt", - ), - requirements=( - TextFileContent( - "status/status.txt", - exact="stav: uspech\nstav: neuspech\n", - error_message=( - "status.txt musí obsahovat přesně oba řádky ve správném pořadí. " - "Zbyl-li jen jeden řádek, přepsalo druhé přesměrování to první." - ), - missing_message="Chybí status.txt. Spusťte oba zadané řetězce příkazů.", - ), - ), - ) - success_message = "Výborně! Dokončili jste Sekci 9 a umíte reagovat na stav příkazu." diff --git a/src/shellgame/levels/solution.py b/src/shellgame/levels/solution.py deleted file mode 100644 index cf21684..0000000 --- a/src/shellgame/levels/solution.py +++ /dev/null @@ -1,350 +0,0 @@ -"""Declarative reference solutions used to prove a level is completable. - -Every other test in the suite proves a level rejects the *wrong* answer. Nothing -proved a level can be *finished*: a requirement pointing at a path the fixture -never creates, or an evidence marker no hook ever writes, makes a level -permanently unwinnable and ships silently. - -A `Solution` is the author's own walkthrough, written in the same -section-relative vocabulary as fixtures and requirements. The harness prepares a -throwaway workspace, replays the steps, and asserts `validate()` then succeeds. - -Solutions are opt-in - most levels are plain enough that their fixture and -requirements cannot disagree - but they are mandatory for the risky shapes -(custom `validate()`, custom `setup()`, `cd` policies, evidence markers), which -`tests/test_solutions.py` enforces. -""" - -from __future__ import annotations - -import os -import shutil -import subprocess -from collections.abc import Sequence -from dataclasses import dataclass, field -from pathlib import Path -from typing import TYPE_CHECKING, Protocol, runtime_checkable - -from shellgame.markers import MarkerManager -from shellgame.paths import WORKSPACE_ROOT, WorkspaceRoot, relative_path, resolve_within - -if TYPE_CHECKING: - from shellgame.levels.base import Level - from shellgame.protocols import GameStateProtocol - -_KIND = "Solution" - - -@dataclass(frozen=True, slots=True) -class SolutionContext: - """Everything a step needs to act on the level under test.""" - - level: Level - state: GameStateProtocol - root: Path - - def path(self, value: str) -> Path: - """Resolve a step path with the same containment rules as requirements.""" - return resolve_within(self.root, value, kind=_KIND) - - def markers(self) -> MarkerManager: - return MarkerManager(self.state.workspace) - - -@runtime_checkable -class SolutionStep(Protocol): - def apply(self, context: SolutionContext) -> None: ... - - -@dataclass(frozen=True, slots=True) -class Chdir: - """Move the player, exactly as a successful `cd` would.""" - - path: str | WorkspaceRoot = "" - - def __post_init__(self) -> None: - if isinstance(self.path, str): - relative_path(self.path, kind=_KIND) - - def apply(self, context: SolutionContext) -> None: - destination = context.state.workspace if isinstance(self.path, WorkspaceRoot) else context.path(self.path) - destination.mkdir(parents=True, exist_ok=True) - os.chdir(destination) - - -@dataclass(frozen=True, slots=True) -class GoHome: - """Satisfy an `AtHome` requirement.""" - - def apply(self, context: SolutionContext) -> None: - del context - os.chdir(Path.home()) - - -@dataclass(frozen=True, slots=True) -class MakeDirectory: - path: str - - def __post_init__(self) -> None: - relative_path(self.path, kind=_KIND) - - def apply(self, context: SolutionContext) -> None: - context.path(self.path).mkdir(parents=True, exist_ok=True) - - -@dataclass(frozen=True, slots=True) -class WriteFile: - """Create a file, optionally with a given number of lines.""" - - path: str - content: str = "" - lines: int | None = None - - def __post_init__(self) -> None: - relative_path(self.path, kind=_KIND) - if self.lines is not None and self.lines < 0: - raise ValueError("WriteFile line count cannot be negative") - - def apply(self, context: SolutionContext) -> None: - destination = context.path(self.path) - destination.parent.mkdir(parents=True, exist_ok=True) - if self.lines is None: - destination.write_text(self.content, encoding="utf-8") - return - body = "".join(f"{self.content or 'radek'} {index + 1}\n" for index in range(self.lines)) - destination.write_text(body, encoding="utf-8") - - -@dataclass(frozen=True, slots=True) -class AppendFile: - path: str - content: str - - def __post_init__(self) -> None: - relative_path(self.path, kind=_KIND) - - def apply(self, context: SolutionContext) -> None: - destination = context.path(self.path) - destination.parent.mkdir(parents=True, exist_ok=True) - with destination.open("a", encoding="utf-8") as handle: - handle.write(self.content) - - -@dataclass(frozen=True, slots=True) -class CopyPath: - source: str - destination: str - - def __post_init__(self) -> None: - relative_path(self.source, kind=_KIND) - relative_path(self.destination, kind=_KIND) - - def apply(self, context: SolutionContext) -> None: - source = context.path(self.source) - destination = context.path(self.destination) - destination.parent.mkdir(parents=True, exist_ok=True) - if source.is_dir(): - shutil.copytree(source, destination, dirs_exist_ok=True) - else: - shutil.copy2(source, destination) - - -@dataclass(frozen=True, slots=True) -class MovePath: - source: str - destination: str - - def __post_init__(self) -> None: - relative_path(self.source, kind=_KIND) - relative_path(self.destination, kind=_KIND) - - def apply(self, context: SolutionContext) -> None: - source = context.path(self.source) - destination = context.path(self.destination) - destination.parent.mkdir(parents=True, exist_ok=True) - shutil.move(str(source), str(destination)) - - -@dataclass(frozen=True, slots=True) -class RemovePath: - path: str - - def __post_init__(self) -> None: - relative_path(self.path, kind=_KIND) - - def apply(self, context: SolutionContext) -> None: - target = context.path(self.path) - if target.is_dir() and not target.is_symlink(): - shutil.rmtree(target) - else: - target.unlink(missing_ok=True) - - -@dataclass(frozen=True, slots=True) -class Chmod: - path: str - mode: int - - def __post_init__(self) -> None: - relative_path(self.path, kind=_KIND) - if not 0 <= self.mode <= 0o7777: - raise ValueError("Chmod mode must be between 0o0000 and 0o7777") - - def apply(self, context: SolutionContext) -> None: - context.path(self.path).chmod(self.mode) - - -@dataclass(frozen=True, slots=True) -class RecordEvidence: - """Write an evidence marker the shell templates would normally produce. - - Use this only for evidence Python cannot generate - `pwd_used` is written by - the shell wrapper. Evidence produced by a `cd_policy` should be earned with - `PerformCd`, which exercises the real hook. - """ - - marker: str - - def apply(self, context: SolutionContext) -> None: - context.markers().create(self.marker) - - -@dataclass(frozen=True, slots=True) -class PerformCd: - """Run the level's real `cd` hook, then move. - - This is the step that proves a strict navigation level is winnable: the hook - must *accept* the intended command and record whatever evidence completion - depends on. Faking the marker instead would hide a hook that rejects the very - command its own instructions ask for - the exact failure that leaves a player - unable to finish a level they solved correctly. - - `absolute` turns a section-relative path into the absolute one the player - would type, for levels that grade *how* the path was written. `move_to` - names the destination for shell-only targets such as `cd -`, which no - filesystem path can express. - """ - - target: str = "" - absolute: bool = False - move_to: str | None = None - - def __post_init__(self) -> None: - if self.absolute: - relative_path(self.target, kind=_KIND) - if self.move_to is not None: - relative_path(self.move_to, kind=_KIND) - - def apply(self, context: SolutionContext) -> None: - hook = context.level.hooks.get("cd") - if hook is None: - raise ValueError(f"{context.level.id}: PerformCd needs a cd hook or a cd_policy") - origin = Path.cwd() - target = str(context.path(self.target)) if self.absolute else self.target - hook(target=target, pwd=str(origin), post_move=False, state=context.state) - os.chdir(self._destination(context, origin, target)) - hook(target=target, pwd=str(Path.cwd()), post_move=True, state=context.state) - - def _destination(self, context: SolutionContext, origin: Path, target: str) -> Path: - if self.move_to is not None: - return context.path(self.move_to) - destination = Path(target).expanduser() - return destination if destination.is_absolute() else origin / destination - - -@dataclass(frozen=True, slots=True) -class WalkHome: - """Walk from `/` to the home directory one segment at a time. - - The destination depends on the machine, so it cannot be spelled out in a - declaration. The level grades the *sequence* of moves rather than the end - state, so the walk has to go through the real hook. - """ - - def apply(self, context: SolutionContext) -> None: - PerformCd("/").apply(context) - for part in Path.home().resolve().parts[1:]: - PerformCd(part).apply(context) - - -@dataclass(frozen=True, slots=True) -class RecordFdEvidence: - """Report the redirection targets the shell wrapper would observe. - - Levels in section 9 grade where the streams were pointed, which is only - visible to the wrapper. The step drives the level's own rule so a wrong - marker name or condition still fails. - """ - - stdout_target: str = "/dev/null" - stderr_target: str = "/dev/null" - - def apply(self, context: SolutionContext) -> None: - context.level.record_fd_evidence( - stdout_target=self.stdout_target, - stderr_target=self.stderr_target, - state=context.state, - ) - - -@dataclass(frozen=True, slots=True) -class RunShell: - """Run a real command, for levels where *how* the result was reached matters. - - Sections 9-11 grade pipelines and redirection, so their solutions have to go - through a shell rather than reproduce the end state directly. - """ - - command: str - - def apply(self, context: SolutionContext) -> None: - del context - subprocess.run(["bash", "-c", self.command], check=True, capture_output=True) # noqa: S603, S607 - - -@dataclass(frozen=True, slots=True) -class Solution: - """A reference walkthrough that must make the level validate successfully.""" - - steps: Sequence[SolutionStep] = field(default_factory=tuple) - answer: str | None = None - start_at: str | WorkspaceRoot | None = None - - def perform(self, level: Level, state: GameStateProtocol) -> None: - """Replay the walkthrough. The caller owns preparing and restoring cwd.""" - context = SolutionContext(level=level, state=state, root=level.section_path(state.workspace)) - start = self._start_directory(level, state) - if start is not None: - start.mkdir(parents=True, exist_ok=True) - os.chdir(start) - for step in self.steps: - step.apply(context) - - def _start_directory(self, level: Level, state: GameStateProtocol) -> Path | None: - if self.start_at is None: - return level.get_start_directory(state.workspace) - if isinstance(self.start_at, WorkspaceRoot): - return state.workspace - return resolve_within(level.section_path(state.workspace), self.start_at, kind=_KIND) - - -__all__ = [ - "WORKSPACE_ROOT", - "AppendFile", - "Chdir", - "Chmod", - "CopyPath", - "GoHome", - "MakeDirectory", - "MovePath", - "PerformCd", - "RecordEvidence", - "RecordFdEvidence", - "RemovePath", - "RunShell", - "Solution", - "SolutionContext", - "SolutionStep", - "WalkHome", - "WriteFile", -] diff --git a/src/shellgame/markers.py b/src/shellgame/markers.py deleted file mode 100644 index 0ddeb72..0000000 --- a/src/shellgame/markers.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Marker file management for shell wrapper communication. - -Marker files are used to verify that the user performed specific shell actions -(like using `pwd`, `cd` with absolute path, etc.) that cannot be detected -directly from Python. - -Markers live directly in the configured workspace. -""" - -from pathlib import Path - -from shellgame.protocols import GameStateProtocol - - -class MarkerManager: - #: Markers shared across levels. A level's own `cd` evidence marker is - #: derived from its ID instead (see `levels.cdpolicy.cd_marker`), so it - #: cannot be misspelled or reused by another level. - PWD_USED = "pwd_used" - LEVEL1_9_CD_WALK_PROGRESS = "level1_9_cd_walk_progress" - LEVEL1_9_CD_WALK_COMPLETED = "level1_9_cd_walk_completed" - LEVEL9_4_DEV_NULL = "level9_4_dev_null_used" - - def __init__(self, workspace: Path): - self.base_dir = workspace - - @classmethod - def from_state(cls, state: GameStateProtocol) -> "MarkerManager": - return cls(state.workspace) - - def _marker_path(self, name: str) -> Path: - marker_name = f".{name}" if not name.startswith(".") else name - return self.base_dir / marker_name - - def exists(self, name: str) -> bool: - return self._marker_path(name).exists() - - def create(self, name: str, content: str = "") -> None: - self.base_dir.mkdir(parents=True, exist_ok=True) - self._marker_path(name).write_text(content) - - def remove(self, name: str) -> None: - self._marker_path(name).unlink(missing_ok=True) - - def read(self, name: str) -> str | None: - path = self._marker_path(name) - if path.exists(): - return path.read_text() - return None diff --git a/src/shellgame/messages.py b/src/shellgame/messages.py deleted file mode 100644 index 02c714a..0000000 --- a/src/shellgame/messages.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Shared validation messages.""" - - -class Messages: - ANSWER_REQUIRED = "Musíte zadat odpověď: shellgame submit <odpověď>" - ANSWER_REQUIRED_NUMBER = "Musíte zadat číslo." - ANSWER_REQUIRED_LIST = "Musíte zadat seznam." - - WRONG_DIRECTORY = "Nejste v cílovém adresáři. Dokončete přesun podle zadání a ověřte svou polohu příkazem 'pwd'." - NOT_AT_HOME = "Nejste doma. Jste v: {actual}" - - FILE_EXISTS = "Soubor '{path}' existuje!" - FILE_NOT_EXISTS = "Soubor '{path}' neexistuje" - FILE_STILL_EXISTS = "Soubor '{path}' stále existuje" - DIR_EXISTS = "Adresář '{path}' existuje!" - DIR_NOT_EXISTS = "Adresář '{path}' neexistuje" - DIR_STILL_EXISTS = "Adresář '{path}' stále existuje" - - FILE_CONTENT_CORRECT = "Obsah souboru je správný!" - FILE_CONTENT_MISMATCH = "Obsah souboru neodpovídá očekávání" - FILE_READ_ERROR = "Nelze přečíst soubor: {error}. Obnovte level příkazem `shellgame reset`." - FILESYSTEM_ERROR = "Chyba při práci se soubory: {error}" - - COPY_SUCCESS = "Správně zkopírováno!" - COPY_SOURCE_MISSING = "Zdrojový soubor '{path}' chybí (možná jste ho přesunuli místo zkopírování?)." - COPY_DEST_MISSING = "Cílový soubor '{path}' neexistuje." - COPY_CONTENT_MISMATCH = "Obsah zkopírovaného souboru neodpovídá originálu." - MOVE_SUCCESS = "Správně přesunuto/přejmenováno!" - MOVE_SOURCE_EXISTS = "Původní soubor '{path}' stále existuje (možná jste ho zkopírovali místo přesunutí?)." - MOVE_DEST_MISSING = "Cílový soubor '{path}' neexistuje." - - PERMISSION_CORRECT = "Správně nastavená oprávnění!" - PERMISSION_WRONG = "Oprávnění nejsou nastavena správně." - - MARKER_NOT_FOUND = "Zatím to nevypadá, že jste použili požadovaný příkaz." - - CD_RECOVERY_TIP = "Ztratili jste se? Příkaz `shellgame reset` vás vrátí na start levelu." - CWD_MISSING = ( - "Aktuální adresář už neexistuje (nejspíš jste ho smazali). Příkaz `shellgame reset` vás vrátí na start levelu." - ) - PATH_ESCAPES_WORKSPACE = "Cesta úkolu opouští pracovní prostor." - - CORRECT = "Správně!" - INCORRECT = "Nesprávně." - ANSWER_FORMAT = "Odpověď nemá očekávaný formát." - COMPARE_FAILED = "Chyba při kontrole souborů: {error}" - EXPECTED_INTEGER = "Očekáváno celé číslo" - - L1_1_USE_PWD_FIRST = ( - "Nejdřív prosím použijte `pwd` (ShellGame sleduje použití příkazu) a pak odpověď odevzdejte znovu." - ) - L1_3_INCLUDED_EXTENSION = "Odevzdejte název souboru bez přípony: vynechte poslední tečku a část za ní." - ABSOLUTE_CD_NOT_USED = ( - "Jste ve správném adresáři, ale zatím to nevypadá, že jste použili absolutní `cd` (začíná na /). Zkuste to " - "znovu: opusťte adresář a vraťte se absolutní cestou." - ) - CD_WALK_NOT_COMPLETED = ( - "Vypadá to, že jste se domů dostali, ale ShellGame nezaznamenal povinnou sekvenci `cd` krok za krokem. " - "Začněte znovu: `cd /` a projděte každý segment cesty do $HOME po jednom." - ) diff --git a/src/shellgame/paths.py b/src/shellgame/paths.py deleted file mode 100644 index 1446e6e..0000000 --- a/src/shellgame/paths.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Filesystem location helpers that tolerate a deleted working directory. - -A player can delete the directory they are standing in (sections 3 and 4 teach -`rm`, `rmdir` and `mv`). After that, `getcwd()` fails for every process the shell -spawns, so `Path.cwd()` raises. Game rules must report that as a normal, -recoverable failure instead of crashing. -""" - -from __future__ import annotations - -from pathlib import Path -from typing import Final - - -def current_directory() -> Path | None: - """Return the resolved working directory, or None when it no longer exists.""" - try: - return Path.cwd().resolve() - except (OSError, RuntimeError): - return None - - -class WorkspaceRoot: - """Sentinel for the rare level that starts at the workspace root. - - Level-declared paths are relative to the level's section root, so an empty - path means "the section root". The handful of levels that deliberately place - the player above their own section (the game "home") say so explicitly - rather than by overloading the empty string. - """ - - __slots__ = () - - def __repr__(self) -> str: - return "WORKSPACE_ROOT" - - -WORKSPACE_ROOT: Final = WorkspaceRoot() - - -class ContainedPathError(ValueError): - """A level-declared path does not stay inside the root it is resolved against.""" - - -def relative_path(value: str, *, kind: str) -> Path: - """Reject anything that is not a plain relative path. - - Level authors declare paths, not locations: an absolute path or a `..` - segment would let a level read or write outside the workspace the game owns. - """ - path = Path(value) - if path.is_absolute() or ".." in path.parts: - raise ContainedPathError(f"{kind} path must be relative to the section root: {value!r}") - return path - - -def resolve_within( - root: Path, - value: str, - *, - kind: str, - allow_leaf_symlink: bool = False, -) -> Path: - """Resolve a level-declared path against `root`, refusing to escape it. - - Fixtures, requirements, start directories and solution steps all speak the - same vocabulary and enforce the same containment, so a level can never - disagree with itself about where it lives - nor reach outside the workspace - by way of `..`, an absolute path, or a symlinked parent. - """ - if root.is_symlink(): - raise ContainedPathError(f"{kind} root cannot be a symlink") - relative = relative_path(value, kind=kind) - candidate = root / relative - containment_target = candidate.parent if allow_leaf_symlink and candidate.is_symlink() else candidate - try: - root_resolved = root.resolve() - candidate_resolved = containment_target.resolve() - except (OSError, RuntimeError) as error: - raise ContainedPathError(f"{kind} path cannot be resolved: {value!r}") from error - if not candidate_resolved.is_relative_to(root_resolved): - raise ContainedPathError(f"{kind} path escapes its root: {value!r}") - - parts = relative.parts[:-1] if allow_leaf_symlink else relative.parts - current = root - for part in parts: - current /= part - if current.is_symlink(): - raise ContainedPathError(f"{kind} path contains a symlink: {value!r}") - return candidate diff --git a/src/shellgame/protocols.py b/src/shellgame/protocols.py deleted file mode 100644 index aa7d01f..0000000 --- a/src/shellgame/protocols.py +++ /dev/null @@ -1,27 +0,0 @@ -from pathlib import Path -from typing import Protocol, runtime_checkable - -ValidationResult = tuple[bool, str] - - -@runtime_checkable -class GameStateProtocol(Protocol): - @property - def workspace(self) -> Path: ... - - @property - def username(self) -> str: ... - - @property - def current_level(self) -> str: ... - - -class CdHookCallback(Protocol): - def __call__( - self, - *, - target: str | None, - pwd: str | None, - post_move: bool, - state: GameStateProtocol, - ) -> None: ... diff --git a/src/shellgame/shell/__init__.py b/src/shellgame/shell/__init__.py deleted file mode 100644 index d975d26..0000000 --- a/src/shellgame/shell/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Shell protocol client.""" - -from shellgame.shell.client import ShellClient - -__all__ = ["ShellClient"] diff --git a/src/shellgame/shell/client.py b/src/shellgame/shell/client.py deleted file mode 100644 index 1923b4b..0000000 --- a/src/shellgame/shell/client.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Shell client for communicating with the shell wrapper.""" - -import base64 -import re -import sys -from pathlib import Path -from typing import TextIO - -PROTOCOL_PREFIX = "__SHELLGAME_EXEC__" -PROTOCOL_VERSION = "v1" -_SUPPORTED_COMMANDS = frozenset({"cd", "export", "echo", "pwd", "exit"}) -_ENV_NAME_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") - - -class ShellClient: - def __init__(self, stream: TextIO = sys.stderr): - self._stream = stream - - def _emit(self, command: str, *args: str | Path) -> None: - if command not in _SUPPORTED_COMMANDS: - raise ValueError(f"Unsupported shell protocol command: {command}") - - encoded_args = [base64.b64encode(str(arg).encode("utf-8")).decode("ascii") for arg in args] - payload = " ".join((PROTOCOL_VERSION, command, *encoded_args)) - - print(f"{PROTOCOL_PREFIX}{payload}", file=self._stream, flush=True) - - def cd(self, path: str | Path) -> None: - self._emit("cd", path) - - def export(self, var_name: str, value: str) -> None: - if not _ENV_NAME_PATTERN.fullmatch(var_name): - raise ValueError(f"Invalid environment variable name: {var_name!r}") - self._emit("export", var_name, value) - - def exit(self) -> None: - self._emit("exit") diff --git a/src/shellgame/state/__init__.py b/src/shellgame/state/__init__.py deleted file mode 100644 index 61532de..0000000 --- a/src/shellgame/state/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""State management module for game persistence.""" diff --git a/src/shellgame/state/manager.py b/src/shellgame/state/manager.py deleted file mode 100644 index e235431..0000000 --- a/src/shellgame/state/manager.py +++ /dev/null @@ -1,186 +0,0 @@ -"""State management for game persistence.""" - -import json -import os -import tempfile -from collections.abc import Callable -from datetime import datetime -from pathlib import Path - -from pydantic import BaseModel, Field, ValidationError - -CURRENT_STATE_VERSION = "2.1" - -#: Section numbers changed in state 2.1: wildcards moved ahead of permissions and -#: redirection, so globbing is taught before the earlier sections rely on it. -_SECTION_ROTATION_2_1 = {7: 8, 8: 9, 9: 10, 10: 7} - -#: Levels swapped inside their section in 2.1 so every section ends on its -#: challenge: the help lesson moved ahead of 2's challenge, the alias aside ahead -#: of 6's. Both directions are listed, which makes the mapping its own inverse. -_LEVEL_SWAPS_2_1 = {"2.7": "2.8", "2.8": "2.7", "6.7": "6.8", "6.8": "6.7"} - -#: Keys in a saved state whose values are keyed by level ID. -_LEVEL_KEYED_FIELDS = ("levels_complete", "level_attempts", "level_hints_used", "level_started_at") - - -class StatePersistenceError(RuntimeError): - """Base error for state loading and saving failures.""" - - -class StateLoadError(StatePersistenceError): - """Raised when an existing state file cannot be loaded safely.""" - - -class StateSaveError(StatePersistenceError): - """Raised when state cannot be written atomically.""" - - -class LevelCompletion(BaseModel): - time_sec: int - hints: int - attempts: int - completed_at: datetime - - -class GameState(BaseModel): - version: str = CURRENT_STATE_VERSION - username: str - workspace: Path - current_level: str - start_time: datetime - - levels_complete: dict[str, LevelCompletion] = Field(default_factory=dict) - - level_attempts: dict[str, int] = Field(default_factory=dict) - level_hints_used: dict[str, int] = Field(default_factory=dict) - level_started_at: dict[str, datetime] = Field(default_factory=dict) - - completed_at: datetime | None = None - - -class StateManager: - def __init__(self) -> None: - state_dir_env = os.environ.get("SHELLGAME_STATE_DIR") - if state_dir_env: - self.state_dir = Path(state_dir_env) - else: - xdg_config = os.environ.get("XDG_CONFIG_HOME") - if xdg_config: - self.state_dir = Path(xdg_config) / "shellgame" - else: - self.state_dir = Path.home() / ".config" / "shellgame" - self.state_file = self.state_dir / "state.json" - - def load(self) -> GameState | None: - if not self.state_file.exists(): - return None - - try: - with self.state_file.open(encoding="utf-8") as f: - data = json.load(f) - return GameState.model_validate(self._migrate(data)) - except (OSError, json.JSONDecodeError, ValidationError, TypeError, ValueError) as exc: - raise StateLoadError(f"Stav ShellGame nelze načíst z {self.state_file}: {exc}") from exc - - def save(self, state: GameState) -> None: - self.state_dir.mkdir(parents=True, exist_ok=True) - tmp_path: Path | None = None - - try: - with tempfile.NamedTemporaryFile( - mode="w", - encoding="utf-8", - dir=self.state_dir, - delete=False, - suffix=".json", - ) as tmp: - tmp_path = Path(tmp.name) - tmp.write(state.model_dump_json(indent=2)) - tmp.flush() - os.fsync(tmp.fileno()) - - os.replace(tmp_path, self.state_file) - except OSError as exc: - raise StateSaveError(f"Stav ShellGame nelze uložit do {self.state_file}: {exc}") from exc - finally: - if tmp_path is not None: - tmp_path.unlink(missing_ok=True) - - def create(self, username: str, workspace_path: Path | None = None) -> GameState: - workspace = workspace_path if workspace_path else self.default_workspace(username) - state = GameState( - username=username, - workspace=workspace, - current_level="0.0", - start_time=datetime.now(), - ) - state.level_started_at[state.current_level] = datetime.now() - return state - - @staticmethod - def default_workspace(username: str) -> Path: - """The only place that derives a workspace path from a username.""" - env_ws = os.environ.get("SHELLGAME_WORKSPACE") - if env_ws: - return Path(env_ws) - return Path(f"/tmp/shellgame-{username}") - - def init(self, username: str, workspace_path: Path | None = None) -> GameState: - state = self.create(username, workspace_path) - self.save(state) - return state - - def exists(self) -> bool: - return self.state_file.exists() - - def remove(self) -> None: - self.state_file.unlink(missing_ok=True) - - def _migrate(self, data: object) -> dict[str, object]: - if not isinstance(data, dict): - raise ValueError("Kořen souboru stavu musí být objekt.") - - migrated = dict(data) - version = str(migrated.get("version", "1.0")) - - if version in {"1.0", "2.0"}: - # Both predate the section reordering, so their level IDs point at - # whatever now happens to carry that number. Remap them explicitly: - # without this the registry's nearest-match fallback would silently - # resume a player on unrelated content and keep their completed - # levels credited to the wrong section. - migrated = _remap_level_ids(migrated, _migrate_level_id_to_2_1) - migrated["version"] = CURRENT_STATE_VERSION - elif version != CURRENT_STATE_VERSION: - raise ValueError(f"Nepodporovaná verze stavu: {version}") - - return migrated - - -def _migrate_level_id_to_2_1(level_id: str) -> str: - """Map a pre-reorder level ID onto the level that now holds the same content.""" - if level_id in _LEVEL_SWAPS_2_1: - return _LEVEL_SWAPS_2_1[level_id] - - section, separator, number = level_id.partition(".") - if not separator or not section.isdigit(): - return level_id - - rotated = _SECTION_ROTATION_2_1.get(int(section)) - return f"{rotated}.{number}" if rotated is not None else level_id - - -def _remap_level_ids(data: dict[str, object], remap: Callable[[str], str]) -> dict[str, object]: - migrated = dict(data) - - current = migrated.get("current_level") - if isinstance(current, str): - migrated["current_level"] = remap(current) - - for field in _LEVEL_KEYED_FIELDS: - value = migrated.get(field) - if isinstance(value, dict): - migrated[field] = {remap(str(key)): item for key, item in value.items()} - - return migrated diff --git a/src/shellgame/ui/__init__.py b/src/shellgame/ui/__init__.py deleted file mode 100644 index ced8389..0000000 --- a/src/shellgame/ui/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""UI module for terminal display and formatting.""" diff --git a/src/shellgame/ui/display.py b/src/shellgame/ui/display.py deleted file mode 100644 index 55995ce..0000000 --- a/src/shellgame/ui/display.py +++ /dev/null @@ -1,471 +0,0 @@ -"""UI display and formatting using Rich.""" - -from __future__ import annotations - -import contextlib -import sys -from pathlib import Path -from typing import Any, cast - -from rich.align import Align -from rich.console import Console, ConsoleOptions, RenderResult -from rich.markdown import Markdown -from rich.panel import Panel -from rich.rule import Rule -from rich.table import Table -from rich.text import Text - -from shellgame.levels.registry import parse_level_id - -Heading = cast(Any, Markdown.elements["heading_open"]) # type: ignore[valid-type] - - -def _level_sort_key(item: tuple[str, Any]) -> tuple[int, int, str]: - """Sort level IDs numerically so that 1.2 precedes 1.10.""" - parsed = parse_level_id(item[0]) - if parsed is None: - return (10**6, 0, item[0]) - return (*parsed, "") - - -class LeftHeading(Heading): # type: ignore[misc, valid-type] - def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: - text = self.text - text.justify = "left" - - level = 1 - try: - level = int(self.tag[1:]) - except Exception: - level = 1 - - if level == 1: - yield Rule(text, style="markdown.h1", align="left") - elif level == 2: - yield Rule(text, style="markdown.h2", align="left") - else: - text.style = f"markdown.h{level}" - yield text - - -class LeftMarkdown(Markdown): - elements = Markdown.elements.copy() - elements["heading_open"] = LeftHeading - - -class Display: - def __init__(self, console: Console): - self.console = console - - def wait_for_continue(self) -> None: - if not sys.stdin.isatty(): - return - with contextlib.suppress(EOFError): - self.console.input(" [dim]Stiskněte Enter pro pokračování...[/dim]") - - def show_instructions(self, level: Any) -> None: - if level.is_intro: - title_str = f"[bold cyan]{level.title}[/bold cyan]" - - raw = level.instructions or "" - pages = [p.strip() for p in raw.split("\n---\n") if p.strip()] - - if not pages: - pages = [raw.strip()] - - total_pages = len(pages) - for idx, page in enumerate(pages, start=1): - self.console.clear() - - md = LeftMarkdown(page, justify="left") - page_title = title_str - if total_pages > 1: - page_title = f"{title_str} [dim](strana {idx}/{total_pages})[/dim]" - - panel = Panel(md, title=page_title, border_style="cyan", padding=(1, 2)) - self.console.print(panel) - - if idx < total_pages: - self.wait_for_continue() - return - - self.console.clear() - md = LeftMarkdown(level.instructions, justify="left") - title_str = f"[bold cyan]{level.id}: {level.title}[/bold cyan]" - panel = Panel(md, title=title_str, border_style="cyan", padding=(1, 2)) - self.console.print(panel) - - if getattr(level, "is_bonus", False): - self.note( - "[dim]Tento level je [bold]nepovinný[/bold]. Můžete ho přeskočit příkazem: " - "[violet]shellgame skip[/violet][/dim]" - ) - self.console.print() - - def note(self, text: str, indent: str = " ") -> None: - for line in text.splitlines(): - self.console.print(indent + line if line else indent.rstrip()) - - _HINT_REPEAT_TIP = ( - "[dim]Tip: Již zobrazené nápovědy si můžete vypsat znovu příkazem: " - "[violet]shellgame hint --repeat[/violet][/dim]" - ) - _HINT_NEXT_HELP = "[dim]Potřebujete další pomoc? Napište: shellgame hint[/dim]" - - def _hint_footer( - self, - hint_num: int, - total_hints: int, - *, - show_repeat_tip: bool = True, - show_next_help: bool = True, - ) -> None: - if show_repeat_tip: - self.note(self._HINT_REPEAT_TIP) - - if hint_num < total_hints - 1: - if show_next_help: - self.note(self._HINT_NEXT_HELP) - self.console.print() - else: - self.console.print() - else: - self.console.print() - - def show_hint( - self, - hint_text: str, - hint_num: int, - total_hints: int, - *, - show_repeat_tip: bool = True, - show_next_help: bool = True, - ) -> None: - panel = Panel( - Text(hint_text), - title=f"[yellow]💡 Nápověda {hint_num + 1} z {total_hints}[/yellow]", - border_style="yellow", - padding=(1, 2), - ) - self.console.print(panel) - self._hint_footer( - hint_num, - total_hints, - show_repeat_tip=show_repeat_tip, - show_next_help=show_next_help, - ) - - def show_no_more_hints(self) -> None: - panel = Panel( - "[yellow]Pro tento level již nejsou k dispozici žádné další nápovědy.[/yellow]", - title="[yellow]💡 Nápověda[/yellow]", - border_style="yellow", - padding=(1, 2), - ) - self.console.print(panel) - self.note(self._HINT_REPEAT_TIP) - self.console.print() - - def show_no_hints_available(self) -> None: - panel = Panel( - "[yellow]Pro tento level nejsou k dispozici žádné nápovědy.[/yellow]", - title="[yellow]💡 Nápověda[/yellow]", - border_style="yellow", - padding=(1, 2), - ) - self.console.print(panel) - self.console.print() - - def show_no_hints_revealed(self) -> None: - panel = Panel( - "[yellow]Zatím jste si nezobrazili žádnou nápovědu.[/yellow]", - title="[yellow]💡 Nápověda[/yellow]", - border_style="yellow", - padding=(1, 2), - ) - self.console.print(panel) - self.note("[dim]První nápovědu zobrazíte příkazem: [violet]shellgame hint[/violet][/dim]") - self.console.print() - - def show_success( - self, - message: str, - time_sec: int | None = None, - hints_used: int | None = None, - attempts: int | None = None, - ) -> None: - body = f"[bold green]{message}[/bold green]" - - stats = self._format_stats(time_sec, hints_used, attempts) - if stats: - body += f"\n\n[dim]{stats}[/dim]" - - panel = Panel( - Align.center(body, vertical="middle"), - border_style="green", - padding=(1, 4), - ) - self.console.print(panel) - - def _format_stats(self, time_sec: int | None, hints_used: int | None, attempts: int | None) -> str: - parts: list[str] = [] - if time_sec is not None: - parts.append(f"Čas: {self._format_duration(time_sec)}") - if hints_used is not None: - parts.append(f"Nápovědy: {hints_used}") - if attempts: - parts.append(f"Chybné pokusy: {attempts}") - return " | ".join(parts) - - def show_failure(self, message: str, suggestion: str = "") -> None: - body = f"[bold red]{message}[/bold red]" - if suggestion: - body += f"\n\n[dim]{suggestion}[/dim]" - - panel = Panel( - Align.center(body, vertical="middle"), - border_style="red", - padding=(1, 4), - ) - self.console.print(panel) - - self.note("[dim]Zkuste to znovu nebo použijte: shellgame hint[/dim]") - self.console.print() - - def show_status(self, state: Any) -> None: - self.console.print("\n[bold cyan]═══ ShellGame Postup ═══[/bold cyan]\n") - - self.console.print(f"[bold]Aktuální Level:[/bold] {state.current_level}") - self.console.print(f"[bold]Zahájeno:[/bold] {state.start_time.strftime('%Y-%m-%d %H:%M')}\n") - - if state.levels_complete: - table = Table(title="Dokončené Levely") - table.add_column("Level", style="cyan") - table.add_column("Čas", justify="right") - table.add_column("Nápovědy", justify="right") - table.add_column("Chybné pokusy", justify="right") - - for level_id, completion in sorted(state.levels_complete.items(), key=_level_sort_key): - table.add_row( - level_id, - self._format_duration(completion.time_sec), - str(completion.hints), - str(completion.attempts), - ) - - self.console.print(table) - - total_time = sum(c.time_sec for c in state.levels_complete.values()) - total_hints = sum(c.hints for c in state.levels_complete.values()) - total_attempts = sum(c.attempts for c in state.levels_complete.values()) - avg_hints = total_hints / len(state.levels_complete) if state.levels_complete else 0 - - self.console.print("\n[bold]Statistiky:[/bold]") - self.console.print(f" Celkový čas: {self._format_duration(total_time)}") - self.console.print(f" Dokončené levely: {len(state.levels_complete)}") - self.console.print(f" Průměrně nápověd: {avg_hints:.1f}") - self.console.print(f" Celkem chybných pokusů: {total_attempts}\n") - else: - self.console.print("[dim]Zatím žádné dokončené levely.[/dim]\n") - - def show_init_success(self, username: str, workspace: str) -> None: - self.console.print(f"\n[green]✓ ShellGame inicializována pro {username}[/green]") - self.console.print(f"[dim]Pracovní prostor: {workspace}[/dim]\n") - - self.console.print("[bold]Automaticky vás přesměrovávám do pracovního prostoru...[/bold]\n") - - def show_already_initialized(self) -> None: - self.console.print("[yellow]Již inicializováno! Spusťte 'shellgame' pro pokračování.[/yellow]\n") - - def show_not_initialized(self) -> None: - self.console.print("[yellow]Neinicializováno. Spusťte: shellgame init[/yellow]\n") - - def show_state_error(self, message: str) -> None: - panel = Panel( - Align.center(f"[bold red]{message}[/bold red]", vertical="middle"), - border_style="red", - padding=(1, 4), - ) - self.console.print(panel) - - def show_removed(self) -> None: - self.console.print("[green]Stav ShellGame a pracovní prostor odstraněny.[/green]") - - def show_reset(self, level_id: str) -> None: - self.console.print(f"[green]✓ Level {level_id} byl resetován.[/green]\n") - - def show_resumed(self, level_id: str) -> None: - self.console.print(f"[green]✓ Pokračujete od levelu {level_id}.[/green]\n") - - _LEVEL_LIST_TIP = "[dim]Seznam všech levelů: [violet]shellgame levels[/violet][/dim]" - - def show_invalid_level_id(self, level_id: str) -> None: - self.console.print(f"[red]Chyba: Neplatné ID levelu: {level_id}[/red]") - self.note( - "[dim]Použijte formát [violet]sekce.level[/violet], například: [violet]shellgame resume 1.4[/violet][/dim]" - ) - self.note(self._LEVEL_LIST_TIP) - self.console.print() - - def show_missing_level_id(self) -> None: - self.console.print("[red]Chyba: Chybí ID levelu.[/red]") - self.note( - "[dim]Použití: [violet]shellgame resume <LEVEL_ID>[/violet], " - "například: [violet]shellgame resume 1.4[/violet][/dim]" - ) - self.console.print() - - def show_unknown_level(self, level_id: str) -> None: - self.console.print(f"[red]Chyba: Level {level_id} neexistuje.[/red]") - self.note(self._LEVEL_LIST_TIP) - self.console.print() - - def show_levels( - self, - levels: list[Any], - *, - current_level: str | None = None, - completed: set[str] | None = None, - ) -> None: - """List every level, grouped by section, with the player's position. - - The section intro (`X.0`) carries the section's name, so it titles the - group instead of becoming a row of its own. - """ - done = completed or set() - - table = Table(title="Dostupné levely") - table.add_column("Level", style="cyan", no_wrap=True) - table.add_column("Název") - table.add_column("", no_wrap=True) - - for level in sorted(levels, key=lambda item: _level_sort_key((item.id, item))): - marker = "" - if level.id == current_level: - marker = "[bold yellow]▸ zde[/bold yellow]" - elif level.id in done: - marker = "[green]✓[/green]" - - name = level.title - if getattr(level, "is_intro", False): - name = f"[bold]{name}[/bold]" - elif getattr(level, "is_bonus", False): - name = f"{name} [dim](nepovinný)[/dim]" - - table.add_row(level.id, name, marker) - - self.console.print(table) - self.note("[dim]Pokračovat od zvoleného levelu: [violet]shellgame resume 1.4[/violet][/dim]") - self.console.print() - - def show_current_directory(self, path: str | Path) -> None: - self.console.print(f"[dim]Aktuální adresář: {path}[/dim]") - - def _format_duration(self, seconds: int) -> str: - if seconds < 60: - return f"{seconds}s" - - minutes = seconds // 60 - remaining_seconds = seconds % 60 - - if minutes < 60: - return f"{minutes}m {remaining_seconds}s" - - hours = minutes // 60 - remaining_minutes = minutes % 60 - return f"{hours}h {remaining_minutes}m {remaining_seconds}s" - - def show_repeated_hints(self, hints: list[str], revealed_count: int) -> None: - total = len(hints) - count = min(revealed_count, total) - - if count == 0: - self.show_no_hints_revealed() - return - - for idx in range(count): - self.show_hint( - hints[idx], - idx, - total, - show_repeat_tip=False, - show_next_help=False, - ) - - self.note(self._HINT_REPEAT_TIP) - - if count < total: - self.note(self._HINT_NEXT_HELP) - self.console.print() - - def show_workspace_restored(self, workspace: str | Path) -> None: - self.console.print("[yellow]⚠ Pracovní prostor byl smazán (např. restart systému). Obnovuji...[/yellow]") - self.console.print(f"[green]✓ Pracovní prostor obnoven: {workspace}[/green]\n") - - def show_level_setup_error(self, level_id: str, error: str) -> None: - panel = Panel( - Align.center( - f"[bold red]Level {level_id} se nepodařilo připravit.[/bold red]\n\n[dim]{error}[/dim]", - vertical="middle", - ), - border_style="red", - padding=(1, 4), - ) - self.console.print(panel) - self.note("[dim]Zkuste: [violet]shellgame reset[/violet][/dim]") - self.console.print() - - def show_level_missing(self, stale_id: str, resolved_id: str) -> None: - """A saved level ID no longer exists (levels were renumbered).""" - panel = Panel( - Align.center( - f"[bold yellow]Level {stale_id} už v této verzi hry neexistuje.[/bold yellow]\n\n" - f"[dim]Pokračujeme nejbližším dostupným levelem: {resolved_id}[/dim]", - vertical="middle", - ), - border_style="yellow", - padding=(1, 4), - ) - self.console.print(panel) - self.note("[dim]Váš dosavadní postup zůstává zachován: [violet]shellgame status[/violet][/dim]") - self.console.print() - - def show_no_levels_available(self) -> None: - panel = Panel( - Align.center( - "[bold red]Nejsou registrovány žádné levely.[/bold red]", - vertical="middle", - ), - border_style="red", - padding=(1, 4), - ) - self.console.print(panel) - self.console.print() - - def show_skipped(self, level_id: str) -> None: - self.console.print(f"[yellow]Level {level_id} přeskočen (byl nepovinný).[/yellow]\n") - - def show_not_skippable(self, level_id: str) -> None: - self.console.print(f"[yellow]Level {level_id} je povinný a nelze ho přeskočit.[/yellow]") - self.note("[dim]Zkuste nápovědu: [violet]shellgame hint[/violet][/dim]") - self.console.print() - - def show_game_complete(self, state: Any) -> None: - total_time = sum(c.time_sec for c in state.levels_complete.values()) - body = ( - "[bold green]Dokončili jste ShellGame![/bold green]\n\n" - f"[dim]Dokončené levely: {len(state.levels_complete)} | " - f"Celkový čas: {self._format_duration(total_time)}[/dim]" - ) - panel = Panel( - Align.center(body, vertical="middle"), - border_style="green", - padding=(1, 4), - ) - self.console.print(panel) - self.note("[dim]Statistiky: [violet]shellgame status[/violet][/dim]") - self.note("[dim]Ukončit hru: [violet]shellgame exit[/violet][/dim]") - self.note( - "[dim]Zahrát znovu od začátku: [violet]shellgame remove[/violet] a pak [violet]shellgame[/violet][/dim]" - ) - self.console.print() diff --git a/src/shellgame/workspace/__init__.py b/src/shellgame/workspace/__init__.py deleted file mode 100644 index 53aa310..0000000 --- a/src/shellgame/workspace/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Workspace management module for directory structure creation.""" diff --git a/src/shellgame/workspace/builder.py b/src/shellgame/workspace/builder.py deleted file mode 100644 index 9c586cc..0000000 --- a/src/shellgame/workspace/builder.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Workspace management for directory structure creation and management.""" - -import shutil -import tempfile -from pathlib import Path - - -class WorkspaceManager: - """Owns one workspace directory. - - The path is always supplied by the caller (normally `GameState.workspace`) - so that initialising, restoring and removing a workspace can never operate - on a different directory than the one the player is actually playing in. - """ - - def __init__(self, workspace: Path): - self.workspace_root = Path(workspace) - - def init(self) -> Path: - self.workspace_root.mkdir(parents=True, exist_ok=True) - return self.workspace_root - - def remove(self) -> None: - if self.workspace_root.exists(): - if not self.validate_integrity(): - raise RuntimeError(f"Refusing to remove unsafe workspace path: {self.workspace_root}") - shutil.rmtree(self.workspace_root) - - def validate_integrity(self) -> bool: - temp_root = Path(tempfile.gettempdir()).resolve() - workspace = self.workspace_root.resolve() - return workspace != temp_root and workspace.is_relative_to(temp_root) diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..fba88b4 --- /dev/null +++ b/src/state.rs @@ -0,0 +1,190 @@ +//! `state.json`, version 3: where the player is and how each visited level went. Positions only +//! (`"S.L"`), no level content. A file of an older version is set aside as `state-old.json`; one +//! of a newer version is an error and is left alone. + +use std::collections::BTreeMap; +use std::fs; +use std::io::{self, ErrorKind, Write}; +use std::path::{Path, PathBuf}; +use std::time::UNIX_EPOCH; + +use serde::{Deserialize, Serialize}; + +use crate::game::Pos; + +pub const FILE: &str = "state.json"; + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Progress { + /// Unix seconds when the level was first shown. + pub started: u64, + pub done: Option<u64>, + pub attempts: u32, + pub hints: u32, +} + +impl Progress { + pub fn seconds(&self) -> u64 { + self.done.map_or(0, |done| done.saturating_sub(self.started)) + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct State { + version: u32, + pub workspace: PathBuf, + /// A position as text: a saved one may no longer exist in this version of the course. + pub level: String, + pub finished: bool, + pub levels: BTreeMap<String, Progress>, +} + +pub fn now() -> u64 { + UNIX_EPOCH.elapsed().map_or(0, |since| since.as_secs()) +} + +impl State { + pub fn new(workspace: PathBuf, at: Pos) -> State { + let mut state = State { version: 3, ..State::default() }; + state.workspace = workspace; + state.go(at); + state + } + + pub fn pos(&self) -> Option<Pos> { + self.level.parse().ok() + } + + /// The progress of a level; its clock starts when it is first asked for. + pub fn at(&mut self, pos: Pos) -> &mut Progress { + let progress = self.levels.entry(pos.to_string()).or_default(); + if progress.started == 0 { + progress.started = now(); + } + progress + } + + /// Makes `pos` the current level; true if anything changed. + pub fn go(&mut self, pos: Pos) -> bool { + let key = pos.to_string(); + let changed = self.level != key || !self.levels.contains_key(&key); + self.level = key; + self.at(pos); + changed + } + + pub fn save(&self, dir: &Path) -> io::Result<()> { + let mut json = serde_json::to_vec_pretty(self)?; + json.push(b'\n'); + replace(dir, FILE, &json).map(drop) + } +} + +/// Writes `<dir>/<name>` (mode 0600), creating `dir`, and returns its path. Atomic: a temporary file +/// in the same directory (Landlock ABI 1 forbids cross-directory renames), synced, then renamed over +/// the old one, so a reader never sees a partial file. +pub fn replace(dir: &Path, name: &str, bytes: &[u8]) -> io::Result<PathBuf> { + fs::create_dir_all(dir)?; + let mut staged = tempfile::Builder::new().prefix(".staged-").tempfile_in(dir)?; + staged.write_all(bytes)?; + staged.as_file().sync_all()?; + let target = dir.join(name); + staged.persist(&target).map_err(|error| error.error)?; + Ok(target) +} + +pub enum Saved { + None, + /// A file of an older version; `retire` sets it aside. It holds where to `resume` in this + /// version when the file says where the player was. + Old(Option<String>), + Game(State), +} + +/// A damaged file, or one of a newer version, is an error with a Czech message and is left alone. +pub fn load(dir: &Path) -> io::Result<Saved> { + let path = dir.join(FILE); + let text = match fs::read_to_string(&path) { + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(Saved::None), + text => text?, + }; + let corrupt = |why: &dyn std::fmt::Display| { + let hint = "Opravte ho, nebo hru odstraňte příkazem: shellgame remove"; + let message = format!("Soubor {} je poškozený ({why}). {hint}", path.display()); + io::Error::new(ErrorKind::InvalidData, message) + }; + let value: serde_json::Value = serde_json::from_str(&text).map_err(|e| corrupt(&e))?; + match value.get("version").and_then(|version| version.as_u64()) { + Some(3) => serde_json::from_value(value).map(Saved::Game).map_err(|e| corrupt(&e)), + Some(newer) if newer > 3 => { + let what = format!("Soubor {} pochází z novější verze ShellGame", path.display()); + let fix = "Aktualizujte hru příkazem: shellgame update"; + Err(io::Error::new( + ErrorKind::InvalidData, + format!("{what} (stav verze {newer}). {fix}"), + )) + } + _ if value.is_object() => Ok(Saved::Old(old_position(&value))), + _ => Err(corrupt(&"není objekt JSON")), + } +} + +/// Where the player of a Python-era save (state version "2.1", whose level ids are this course's +/// positions, `S.0` being a section's intro) continues: `S.L`, or `S` for an intro. +fn old_position(value: &serde_json::Value) -> Option<String> { + if value.get("version")?.as_str()? != "2.1" { + return None; + } + let level = value.get("current_level")?.as_str()?; + let target = level.strip_suffix(".0").unwrap_or(level); + target.parse::<Pos>().is_ok().then(|| target.to_owned()) +} + +/// Renames the saved file to `state-old.json` (`state-old-2.json`, ... if taken), never overwriting; +/// returns the new path. +pub fn retire(dir: &Path) -> io::Result<PathBuf> { + let (mut name, mut n) = ("state-old.json".to_owned(), 1); + while dir.join(&name).exists() { + n += 1; + name = format!("state-old-{n}.json"); + } + fs::rename(dir.join(FILE), dir.join(&name))?; + Ok(dir.join(name)) +} + +pub fn remove(dir: &Path) -> io::Result<()> { + match fs::remove_file(dir.join(FILE)) { + Err(error) if error.kind() != ErrorKind::NotFound => Err(error), + _ => Ok(()), + } +} + +/// The workspace of a saved game of any version, even a damaged one, as far as it can be read. +pub fn workspace_of(dir: &Path) -> Option<PathBuf> { + let value: serde_json::Value = + serde_json::from_str(&fs::read_to_string(dir.join(FILE)).ok()?).ok()?; + value.get("workspace")?.as_str().map(PathBuf::from) +} + +#[cfg(test)] +mod tests { + use super::*; + + const AT: Pos = Pos { section: 3, level: 2 }; + + #[test] + fn a_game_survives_a_save_and_leaves_no_temporary_file() { + let dir = tempfile::tempdir().unwrap(); + let mut state = State::new("/tmp/shellgame-ada".into(), AT); + state.at(AT).attempts = 2; + state.at(AT).done = Some(state.at(AT).started + 7); + state.save(dir.path()).unwrap(); + state.finished = true; + state.save(dir.path()).unwrap(); + let Saved::Game(loaded) = load(dir.path()).unwrap() else { panic!("a game") }; + assert_eq!(loaded, state); + assert_eq!((loaded.pos(), loaded.levels["3.2"].seconds()), (Some(AT), 7)); + let names = fs::read_dir(dir.path()).unwrap().map(|entry| entry.unwrap().file_name()); + assert_eq!(names.collect::<Vec<_>>(), [FILE]); + } +} diff --git a/src/ui/markdown.rs b/src/ui/markdown.rs new file mode 100644 index 0000000..99bdc53 --- /dev/null +++ b/src/ui/markdown.rs @@ -0,0 +1,246 @@ +//! CommonMark for the terminal, laid out for a width in one pass over the parser's events. +//! +//! Paragraphs reflow (the sources break lines by hand), headings are left-aligned (levels 1 and 2 +//! are followed by a rule), list items hang under their text, code blocks are verbatim and never +//! wrapped (the panel folds them), quotes get a `▌` bar. Blocks are separated by one blank line, +//! items of a list by none. Raw HTML is shown as text; tables and other extensions are not enabled. +//! A thematic break (`---`, which splits an intro into [`pages`]) draws nothing, a hard line break +//! is a space. + +use anstyle::Style; +use pulldown_cmark::{Event, HeadingLevel, Parser, Tag, TagEnd}; + +use super::{ + BOLD, BOLD_CYAN, BOLD_YELLOW, CYAN, DIM_BOLD, Line, Piece, Span, line_width, reflow, span, + whole, width, words, +}; + +const MAGENTA: Style = anstyle::AnsiColor::Magenta.on_default(); + +/// Pages of an intro: split at lines of `---`, trimmed, empty ones dropped. +pub fn pages(text: &str) -> Vec<&str> { + text.split("\n---\n").map(str::trim).filter(|page| !page.is_empty()).collect() +} + +/// `top` over `base`: its colour wins where it has one, effects add up. +fn layer(base: Style, top: Style) -> Style { + Style::new() + .fg_color(top.get_fg_color().or(base.get_fg_color())) + .effects(base.get_effects() | top.get_effects()) +} + +/// An enclosing list item or quote: what starts its first line, and what every later line. +struct Container { + marker: Option<Span>, + hang: Span, + quote: bool, +} + +#[derive(Default)] +struct Renderer { + cols: usize, + out: Vec<Line>, + stack: Vec<Container>, + styles: Vec<Style>, + pieces: Vec<Piece>, + /// The next number of each open list (`None` for bullets). + lists: Vec<Option<u64>>, + code: Option<String>, + heading: Option<HeadingLevel>, + /// A blank line is due before the next block. + gap: bool, +} + +pub fn render(md: &str, cols: usize) -> Vec<Line> { + let mut r = Renderer { cols, styles: vec![Style::new()], ..Renderer::default() }; + Parser::new(md).for_each(|event| r.event(event)); + r.out +} + +impl Renderer { + fn style(&self) -> Style { + self.styles.last().copied().unwrap_or_default() + } + + fn room(&self) -> usize { + let indent: usize = self.stack.iter().map(|c| width(&c.hang.text)).sum(); + self.cols.saturating_sub(indent).max(1) + } + + fn blank(&mut self) { + let bars = self.stack.iter().filter(|c| c.quote); + self.out.push(bars.map(|c| span(c.hang.text.trim_end(), c.hang.style)).collect()); + } + + fn separate(&mut self) { + if std::mem::take(&mut self.gap) { + self.blank(); + } + } + + fn emit(&mut self, lines: Vec<Line>) { + for line in lines { + let mut full: Line = self + .stack + .iter_mut() + .map(|c| c.marker.take().unwrap_or_else(|| c.hang.clone())) + .collect(); + full.extend(line); + self.out.push(full); + } + } + + /// Lays the collected inline text out as a paragraph or heading. Text of a tight list item is + /// flushed without a gap after it, so a nested list follows directly. + fn flush(&mut self, gap_after: bool) { + if self.pieces.is_empty() { + return; + } + self.separate(); + let cols = self.room(); + let mut lines = reflow(&std::mem::take(&mut self.pieces), cols); + if let (Some(HeadingLevel::H1 | HeadingLevel::H2), Some(last)) = + (self.heading.take(), lines.last_mut()) + { + let used = line_width(last); + if used + 2 <= cols { + last.extend([span(" ", Style::new()), span("─".repeat(cols - used - 1), BOLD)]); + } + } + self.emit(lines); + self.gap = gap_after; + } + + fn event(&mut self, event: Event) { + let style = self.style(); + match event { + Event::End(TagEnd::Paragraph) => self.flush(true), + Event::Start(Tag::Heading { level, .. }) => { + self.heading = Some(level); + self.styles.push(layer(style, heading_style(level))); + } + Event::End(TagEnd::Heading(_)) => { + self.styles.pop(); + self.flush(true); + } + Event::Start(Tag::BlockQuote(_)) => { + self.flush(false); + self.separate(); + let bar = span("▌ ", MAGENTA); + self.stack.push(Container { marker: None, hang: bar, quote: true }); + self.styles.push(layer(style, MAGENTA)); + } + Event::End(TagEnd::BlockQuote(_)) => { + self.flush(false); + self.stack.pop(); + self.styles.pop(); + self.gap = true; + } + Event::Start(Tag::List(first)) => { + self.flush(false); + self.separate(); + self.lists.push(first); + } + Event::End(TagEnd::List(_)) => { + self.lists.pop(); + self.gap = true; + } + Event::Start(Tag::Item) => { + let next = self.lists.last_mut().and_then(Option::as_mut); + let marker = + next.as_deref().map_or(" • ".to_owned(), |number| format!(" {number} ")); + next.into_iter().for_each(|number| *number += 1); + self.gap = false; + let hang = span(" ".repeat(marker.chars().count()), Style::new()); + let marker = Some(span(marker, BOLD_YELLOW)); + self.stack.push(Container { marker, hang, quote: false }); + } + Event::End(TagEnd::Item) => { + self.flush(false); + if let Some(Container { marker: Some(marker), .. }) = self.stack.pop() { + self.out.push(vec![span(marker.text.trim_end(), marker.style)]); + } + } + Event::Start(Tag::CodeBlock(_) | Tag::HtmlBlock) => { + self.flush(false); + self.separate(); + self.code = Some(String::new()); + } + Event::End(TagEnd::CodeBlock | TagEnd::HtmlBlock) => { + let text = self.code.take().unwrap_or_default(); + let mut lines: Vec<&str> = text.lines().map(str::trim_end).collect(); + while lines.last() == Some(&"") { + lines.pop(); + } + let rows = lines.iter().map(|line| { + if line.is_empty() { + Line::new() + } else { + vec![span(format!(" {line}"), CYAN)] + } + }); + self.emit(rows.collect()); + self.gap = true; + } + Event::Start(Tag::Emphasis) => self.styles.push(layer(style, Style::new().italic())), + Event::Start(Tag::Strong) => self.styles.push(layer(style, BOLD)), + Event::End(TagEnd::Emphasis | TagEnd::Strong) => { + self.styles.pop(); + } + Event::Text(text) | Event::Html(text) | Event::InlineHtml(text) => match &mut self.code + { + Some(code) => code.push_str(&text), + None => self.pieces.push(words(style, &text)), + }, + Event::Code(code) => self.pieces.push(whole(layer(style, BOLD_CYAN), &code)), + Event::SoftBreak | Event::HardBreak => self.pieces.push(words(style, " ")), + _ => {} + } + } +} + +fn heading_style(level: HeadingLevel) -> Style { + match level { + HeadingLevel::H1 | HeadingLevel::H2 => BOLD.underline(), + HeadingLevel::H3 => BOLD, + _ => DIM_BOLD, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::game::dedent; + use crate::levels::COURSE; + use crate::ui::plain; + + fn doc(md: &str, cols: usize) -> String { + render(md, cols).iter().map(|line| plain(line)).collect::<Vec<_>>().join("|") + } + + #[test] + fn pages_are_split_at_rules_and_lists_wrap_under_their_marker() { + assert_eq!(pages("a\n\n---\n\nb\n---\n \n---\nc"), ["a", "b", "c"]); + let loose = "- jedna dlouhá položka\n\n druhý odstavec\n- další"; + assert_eq!(doc(loose, 14), " • jedna| dlouhá| položka|| druhý| odstavec| • další"); + } + + #[test] + fn nothing_but_code_is_wider_than_the_width() { + let levels = + COURSE.iter().flat_map(|section| section.levels.iter().map(|level| level.text)); + let texts = COURSE.iter().map(|section| section.intro).chain(levels); + for page in texts + .flat_map(|text| pages(&dedent(text)).into_iter().map(String::from).collect::<Vec<_>>()) + { + for cols in [40, 80] { + for line in render(&page, cols) + .iter() + .filter(|line| !line.iter().any(|span| span.style == CYAN)) + { + assert!(line_width(line) <= cols, "{:?} at {cols}", plain(line)); + } + } + } + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..422e721 --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,335 @@ +//! Terminal output. Text stays structured (styled [`Span`]s in [`Line`]s) until [`Term::print`] +//! turns it into escape sequences, so layout is plain arithmetic on column widths. + +pub mod markdown; +pub mod panel; +pub mod prompt; +pub mod screens; + +use std::io::{self, IsTerminal, Write}; + +use anstream::{AutoStream, ColorChoice}; +use anstyle::{Ansi256Color, AnsiColor, Style}; +use rustix::{stdio, termios}; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +pub use panel::Panel; + +pub const PLAIN: Style = Style::new(); +pub const BOLD: Style = Style::new().bold(); +pub const DIM: Style = Style::new().dimmed(); +pub const DIM_BOLD: Style = DIM.bold(); +pub const CYAN: Style = AnsiColor::Cyan.on_default(); +pub const BOLD_CYAN: Style = CYAN.bold(); +pub const YELLOW: Style = AnsiColor::Yellow.on_default(); +pub const BOLD_YELLOW: Style = YELLOW.bold(); +pub const GREEN: Style = AnsiColor::Green.on_default(); +pub const BOLD_GREEN: Style = GREEN.bold(); +pub const RED: Style = AnsiColor::Red.on_default(); +pub const BOLD_RED: Style = RED.bold(); +pub const VIOLET: Style = Ansi256Color(177).on_default(); +pub const DIM_VIOLET: Style = VIOLET.dimmed(); + +#[derive(Clone, Debug, PartialEq)] +pub struct Span { + pub text: String, + pub style: Style, +} + +pub type Line = Vec<Span>; + +/// A span of `text`, made safe to print: control characters (a file name must not drive the +/// terminal) become U+FFFD, a tab four spaces, and the emoji variation selector is dropped because +/// terminals disagree about the width of `⚠️` (the text form is one column everywhere). +pub fn span(text: impl AsRef<str>, style: Style) -> Span { + let text = text.as_ref().replace('\u{fe0f}', "").replace('\t', " "); + let clean = text.chars().map(|c| if c.is_control() { '\u{fffd}' } else { c }); + Span { text: clean.collect(), style } +} + +pub fn width(text: &str) -> usize { + UnicodeWidthStr::width(text) +} + +pub fn line_width(line: &[Span]) -> usize { + line.iter().map(|span| width(&span.text)).sum() +} + +pub fn plain(line: &[Span]) -> String { + line.iter().map(|span| span.text.as_str()).collect() +} + +fn push_char(line: &mut Line, c: char, style: Style) { + match line.last_mut() { + Some(last) if last.style == style => last.text.push(c), + _ => line.push(span(c.to_string(), style)), + } +} + +/// Splits a line after at most `cols` columns (always taking one character, so progress is certain). +pub fn split_at_width(line: Line, cols: usize) -> (Line, Line) { + let (mut head, mut tail, mut used) = (Line::new(), Line::new(), 0); + for Span { text, style } in line { + for c in text.chars() { + let w = c.width().unwrap_or(0); + if tail.is_empty() && (used == 0 || used + w <= cols) { + used += w; + push_char(&mut head, c, style); + } else { + push_char(&mut tail, c, style); + } + } + } + (head, tail) +} + +/// At most `cols` columns, ending in `…` when something was cut. +pub fn truncate(line: Line, cols: usize) -> Line { + if line_width(&line) <= cols { + return line; + } + let style = line.last().map_or(PLAIN, |last| last.style); + let mut head = split_at_width(line, cols.saturating_sub(1)).0; + if cols > 0 { + head.push(span("…", style)); + } + head +} + +/// Inline content to lay out. +pub enum Piece { + /// Breaks at its spaces. + Words(Span), + /// A command or path: kept in one piece as long as it fits a row. + Whole(Span), +} + +pub fn words(style: Style, text: &str) -> Piece { + Piece::Words(span(text, style)) +} + +pub fn whole(style: Style, text: &str) -> Piece { + Piece::Whole(span(text, style)) +} + +#[derive(Default)] +struct Rows { + done: Vec<Line>, + row: Line, + used: usize, + cols: usize, +} + +impl Rows { + fn end_row(&mut self) { + self.done.push(std::mem::take(&mut self.row)); + self.used = 0; + } + + /// Puts a word after its `gap` on the row, or on the next one; a word wider than a whole row is + /// split between characters. + fn put(&mut self, gap: Option<Span>, word: Line) { + if word.is_empty() { + return; + } + let (gap_width, word_width) = + (gap.as_ref().map_or(0, |gap| width(&gap.text)), line_width(&word)); + if self.used > 0 && self.used + gap_width + word_width > self.cols { + self.end_row(); + } + if let (true, Some(gap)) = (self.used > 0, gap) { + self.row.push(gap); + self.used += gap_width; + } + if self.used + word_width <= self.cols { + self.used += word_width; + self.row.extend(word); + return; + } + for Span { text, style } in word { + for c in text.chars() { + let w = c.width().unwrap_or(0); + if self.used > 0 && self.used + w > self.cols { + self.end_row(); + } + push_char(&mut self.row, c, style); + self.used += w; + } + } + } +} + +/// Greedy line breaking at spaces. A run of spaces is kept inside a row and dropped at a break. +pub fn reflow(pieces: &[Piece], cols: usize) -> Vec<Line> { + let cols = cols.max(1); + let mut rows = Rows { cols, ..Rows::default() }; + let (mut word, mut gap) = (Line::new(), None); + for piece in pieces { + match piece { + Piece::Whole(span) if width(&span.text) <= rows.cols => word.push(span.clone()), + Piece::Words(span) | Piece::Whole(span) => { + let mut rest = span.text.as_str(); + while let Some(first) = rest.chars().next() { + let end = + rest.find(|c: char| (c == ' ') != (first == ' ')).unwrap_or(rest.len()); + let (run, tail) = rest.split_at(end); + rest = tail; + if first == ' ' { + rows.put(gap.take(), std::mem::take(&mut word)); + gap = Some(self::span(run, span.style)); + } else { + word.push(self::span(run, span.style)); + } + } + } + } + } + rows.put(gap.take(), word); + if !rows.row.is_empty() { + rows.end_row(); + } + rows.done +} + +/// Words, with every span quoted by `'` or `` ` `` (a command in a hint or message) kept whole. +fn quoted(text: &str, style: Style) -> Vec<Piece> { + let (mut pieces, mut rest) = (vec![], text); + while let Some(open) = rest.find(['\'', '`']) { + let quote = &rest[open..open + 1]; + let Some(close) = rest[open + 1..].find(quote) else { break }; + let end = open + 1 + close + 1; + pieces.extend([words(style, &rest[..open]), whole(style, &rest[open..end])]); + rest = &rest[end..]; + } + pieces.push(words(style, rest)); + pieces +} + +/// Text exactly as written, wrapped at spaces (hints and messages are never markdown): newlines +/// and blank lines stay, a continuation row repeats its line's indentation, and a quoted command +/// stays on one row when it fits. +pub fn wrap_plain(text: &str, cols: usize, style: Style) -> Vec<Line> { + let mut out = Vec::new(); + for raw in text.lines() { + let body = raw.trim_start_matches(' '); + let indent = (raw.len() - body.len()).min(cols / 2); + if body.trim().is_empty() { + out.push(Line::new()); + } + for mut row in reflow("ed(body.trim_end(), style), cols - indent) { + row.insert(0, span(" ".repeat(indent), PLAIN)); + out.push(row); + } + } + out +} + +/// Where output goes: the width of the layout, whether to emit colour, which stream. +#[derive(Clone, Copy, Debug)] +pub struct Term { + pub width: usize, + pub color: bool, + pub stderr: bool, +} + +fn wants_color(stream: &impl anstream::stream::RawStream) -> bool { + AutoStream::choice(stream) != ColorChoice::Never +} + +pub fn write_now(mut out: impl Write, bytes: &[u8]) { + let _ = out.write_all(bytes).and_then(|()| out.flush()); +} + +/// The columns of the first of stdout, stderr and stdin that is a terminal; 80 when none is. +fn terminal_width() -> usize { + let fds = [stdio::stdout(), stdio::stderr(), stdio::stdin()]; + let size = + fds.into_iter().find_map(|fd| termios::tcgetwinsize(fd).ok().filter(|s| s.ws_col > 0)); + size.map_or(80, |size| usize::from(size.ws_col)).max(40) +} + +impl Term { + fn new(color: bool, stderr: bool) -> Term { + Term { width: terminal_width(), color, stderr } + } + + /// Colour follows the usual rules (`NO_COLOR`, `CLICOLOR`, a terminal). + pub fn stdout() -> Term { + Term::new(wants_color(&io::stdout()), false) + } + + /// The same rules, for the live stderr of a hook. + pub fn stderr() -> Term { + Term::new(wants_color(&io::stderr()), true) + } + + /// One string; adjacent spans of one style share their escape sequences. + pub fn render(&self, line: &[Span]) -> String { + let mut runs: Vec<(Style, String)> = Vec::new(); + for span in line { + match runs.last_mut() { + Some((style, text)) if *style == span.style => text.push_str(&span.text), + _ => runs.push((span.style, span.text.clone())), + } + } + let paint = + |(style, text): (Style, String)| match self.color && style != PLAIN && !text.is_empty() + { + true => format!("{}{text}{}", style.render(), style.render_reset()), + false => text, + }; + runs.into_iter().map(paint).collect() + } + + /// A closed or broken stream is not worth failing a command for. + pub fn print(&self, lines: &[Line]) { + let text: String = lines.iter().map(|line| self.render(line) + "\n").collect(); + if self.stderr { + write_now(io::stderr(), text.as_bytes()); + } else { + write_now(io::stdout(), text.as_bytes()); + } + } + + /// Clears a terminal screen before a panel; piped output never gets the escape sequences. + pub fn clear(&self) { + if io::stdout().is_terminal() { + write_now(io::stdout(), b"\x1b[2J\x1b[H"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn text(lines: Vec<Line>) -> Vec<String> { + lines.iter().map(|row| plain(row)).collect() + } + + #[test] + fn text_wraps_at_spaces_keeps_commands_whole_and_splits_long_words() { + let prose = [words(PLAIN, "jedna dva tři čtyři")]; + assert_eq!(text(reflow(&prose, 9)), ["jedna dva", "tři čtyři"]); + assert_eq!(text(reflow(&[words(PLAIN, "abcdefghij")], 4)), ["abcd", "efgh", "ij"]); + let command = + [words(PLAIN, "alpha beta "), whole(PLAIN, "cd ../finish"), words(PLAIN, " gamma")]; + assert_eq!(text(reflow(&command, 20)), ["alpha beta", "cd ../finish gamma"]); + assert_eq!( + text(reflow(&[whole(PLAIN, "cd ../finish now")], 10)), + ["cd", "../finish", "now"] + ); + } + + #[test] + fn plain_text_is_verbatim_and_spans_are_safe() { + let hint = "použijte `ls [ab]*.txt` zde\n\n odsazený řádek je dlouhý"; + let wrapped = + ["použijte", "`ls [ab]*.txt`", "zde", "", " odsazený", " řádek je", " dlouhý"]; + assert_eq!(text(wrap_plain(hint, 14, PLAIN)), wrapped); + assert_eq!(span("a\x1b[31mb\tc⚠\u{fe0f}", PLAIN).text, "a\u{fffd}[31mb c⚠"); + let line = vec![span("příliš ", BOLD), span("žluťoučký", PLAIN)]; + assert_eq!(plain(&truncate(line.clone(), 8)), "příliš …"); + assert_eq!(plain(&split_at_width(line, 7).0), "příliš "); + } +} diff --git a/src/ui/panel.rs b/src/ui/panel.rs new file mode 100644 index 0000000..63f1f28 --- /dev/null +++ b/src/ui/panel.rs @@ -0,0 +1,99 @@ +//! A rounded box with the title centred in the top border. +//! +//! ```text +//! ╭──────────────────── Title ─────────────────────╮ +//! │ │ +//! │ Body lines, padded to the inner width. │ +//! │ │ +//! ╰────────────────────────────────────────────────╯ +//! ``` + +use anstyle::Style; + +use super::{Line, PLAIN, line_width, plain, span, split_at_width, truncate}; + +#[derive(Default)] +pub struct Panel { + pub border: Style, + pub title: Line, + /// Blank rows above and below the body, blank columns left and right of it. + pub pad: (usize, usize), + pub center: bool, +} + +impl Panel { + /// Columns for the body in a panel `outer` columns wide. + pub fn inner(&self, outer: usize) -> usize { + outer.saturating_sub(2 + 2 * self.pad.1).max(1) + } + + /// A body line wider than the panel (code is never wrapped) continues on further rows that + /// keep its indentation. + fn fold(mut line: Line, cols: usize) -> Vec<Line> { + let text = plain(&line); + let indent = (text.len() - text.trim_start_matches(' ').len()).min(cols / 2); + let mut rows = Vec::new(); + while line_width(&line) > cols { + let (head, tail) = split_at_width(line, cols); + rows.push(head); + line = [span(" ".repeat(indent), PLAIN)].into_iter().chain(tail).collect(); + } + rows.push(line); + rows + } + + pub fn render(&self, body: Vec<Line>, outer: usize) -> Vec<Line> { + let (outer, gap) = (outer.max(3), " ".repeat(self.pad.1)); + let inner = self.inner(outer); + let border = |text: String| span(text, self.border); + let line = |left: &str, fill: &str, right: &str| { + vec![border(format!("{left}{}{right}", fill.repeat(outer - 2)))] + }; + let blank = || vec![border(format!("│{}│", " ".repeat(outer - 2)))]; + let title = truncate(self.title.clone(), outer.saturating_sub(6)); + let mut top = line("╭", "─", "╮"); + if !title.is_empty() { + let fill = outer - 4 - (line_width(&title) + 2); + top = vec![border(format!("╭─{} ", "─".repeat(fill / 2)))]; + top.extend(title); + top.push(border(format!(" {}─╮", "─".repeat(fill - fill / 2)))); + } + let mut out = vec![top]; + out.extend((0..self.pad.0).map(|_| blank())); + for row in body.into_iter().flat_map(|line| Panel::fold(line, inner)) { + let missing = inner - line_width(&row); + let left = if self.center { missing / 2 } else { 0 }; + let mut framed = vec![border(format!("│{gap}{}", " ".repeat(left)))]; + framed.extend(row); + framed.push(border(format!("{}{gap}│", " ".repeat(missing - left)))); + out.push(framed); + } + out.extend((0..self.pad.0).map(|_| blank())); + out.push(line("╰", "─", "╯")); + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ui::{BOLD, CYAN}; + + fn text(lines: Vec<Line>) -> Vec<String> { + lines.iter().map(|line| plain(line)).collect() + } + + #[test] + fn every_row_is_as_wide_as_the_panel_and_a_long_line_keeps_its_indent() { + let long = vec![span(" 0123456789abcdef0123456789abcdef", PLAIN)]; + let body = vec![vec![span("Příliš žluťoučký kůň 💡 ✓", PLAIN)], Line::new(), long]; + let title = vec![span("1.2: Velmi dlouhý název, který se nevejde", BOLD)]; + let panel = Panel { border: CYAN, title, pad: (1, 2), ..Panel::default() }; + for outer in [24, 40, 61] { + assert!(panel.render(body.clone(), outer).iter().all(|row| line_width(row) == outer)); + } + let panel = Panel { pad: (0, 1), ..Panel::default() }; + let rows = text(panel.render(vec![vec![span(" 0123456789abcdef", PLAIN)]], 18)); + assert_eq!(rows[1..3], ["│ 0123456789ab │", "│ cdef │"]); + } +} diff --git a/src/ui/prompt.rs b/src/ui/prompt.rs new file mode 100644 index 0000000..b750da4 --- /dev/null +++ b/src/ui/prompt.rs @@ -0,0 +1,106 @@ +//! Prompts. They go to stdout: the shell function holds stderr back until the command ends. + +use std::io::{self, BufRead, IsTerminal, Read}; + +use rustix::termios::{self, LocalModes, OptionalActions, SpecialCodeIndex, Termios}; + +use super::{DIM, Term, span, write_now}; + +/// Waits for Enter. The terminal is put in non-canonical mode with `ISIG` off, so Ctrl-C is an +/// ordinary key: the process is not killed mid-prompt, the command ends normally and the shell +/// function still replays the directives emitted before. False when the player pressed Ctrl-C or +/// Ctrl-D (or the input ended); true on Enter, and without asking when stdin or stdout is no +/// terminal, so a pipe is never swallowed by a prompt nobody can see. +pub fn pause(term: &Term) -> bool { + if !io::stdin().is_terminal() || !io::stdout().is_terminal() { + return true; + } + let prompt = term.render(&[span(" Stiskněte Enter pro pokračování...", DIM)]); + write_now(io::stdout(), prompt.as_bytes()); + // A terminal that refuses the mode change is no terminal for prompts. + let Ok(_raw) = RawMode::enable() else { + return true; + }; + let enter = read_until_enter(&mut RawStdin); + // Echo is off, so the cursor did not move to the next line by itself. + write_now(io::stdout(), b"\n"); + enter +} + +/// `<question> [y/N]: `, one line from stdin even when it is a pipe. Only yes (`y`, `yes`, `a`, +/// `ano`, any case) is yes: empty input, end of input and anything else decline. +pub fn confirm(question: &str) -> bool { + write_now(io::stdout(), format!("{question} [y/N]: ").as_bytes()); + let mut answer = String::new(); + let read = io::stdin().lock().read_line(&mut answer); + if !io::stdin().is_terminal() { + write_now(io::stdout(), b"\n"); + } + read.is_ok_and(|bytes| bytes > 0) + && matches!(answer.trim().to_lowercase().as_str(), "y" | "yes" | "a" | "ano") +} + +fn read_until_enter(input: &mut impl Read) -> bool { + let mut key = [0_u8; 1]; + loop { + match input.read(&mut key) { + Ok(1) => match key[0] { + b'\n' | b'\r' => return true, + 0x03 | 0x04 => return false, + _ => {} + }, + Err(error) if error.kind() == io::ErrorKind::Interrupted => {} + _ => return false, + } + } +} + +/// Unbuffered reads from stdin: `io::Stdin` would swallow keys typed after Enter, which are the shell's. +struct RawStdin; + +impl Read for RawStdin { + fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { + rustix::io::read(rustix::stdio::stdin(), buf).map_err(io::Error::from) + } +} + +struct RawMode { + saved: Termios, +} + +impl RawMode { + fn enable() -> rustix::io::Result<RawMode> { + let stdin = rustix::stdio::stdin(); + let saved = termios::tcgetattr(stdin)?; + let mut raw = saved.clone(); + raw.local_modes + .remove(LocalModes::ICANON | LocalModes::ECHO | LocalModes::ISIG | LocalModes::IEXTEN); + raw.special_codes[SpecialCodeIndex::VMIN] = 1; + raw.special_codes[SpecialCodeIndex::VTIME] = 0; + termios::tcsetattr(stdin, OptionalActions::Drain, &raw)?; + Ok(RawMode { saved }) + } +} + +impl Drop for RawMode { + fn drop(&mut self) { + let _ = termios::tcsetattr(rustix::stdio::stdin(), OptionalActions::Now, &self.saved); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_enter_continues_and_ctrl_c_or_the_end_of_input_stop() { + let enter = |keys: &[u8]| read_until_enter(&mut &*keys); + assert!(enter(b"\n") && enter(b"\r") && enter(b"abc \x1b[A\x7f\n")); + assert!(!enter(b"\x03") && !enter(b"ab\x03\n") && !enter(b"\x04") && !enter(b"abc")); + let mut keys: &[u8] = b"\nrest"; + assert!( + read_until_enter(&mut keys) && keys == b"rest", + "what follows Enter is the shell's" + ); + } +} diff --git a/src/ui/screens.rs b/src/ui/screens.rs new file mode 100644 index 0000000..2054804 --- /dev/null +++ b/src/ui/screens.rs @@ -0,0 +1,350 @@ +//! What the player sees: panels and notes built from plain data. Czech text for the player; hints, +//! messages, paths and names are printed verbatim (never as markdown), only a level's own text +//! goes through the markdown renderer. + +use anstyle::Style; + +use super::{ + BOLD, BOLD_CYAN, BOLD_GREEN, BOLD_RED, BOLD_YELLOW, CYAN, DIM, DIM_VIOLET, GREEN, Line, PLAIN, + Panel, Piece, RED, Term, VIOLET, YELLOW, markdown, reflow, span, whole, words, wrap_plain, +}; +use crate::game::{Pos, dedent, messages}; +use crate::levels::COURSE; +use crate::state::{Progress, State}; + +/// A paragraph in one style, which may span several lines, and a blank line. +pub fn say(term: &Term, style: Style, text: &str) -> Vec<Line> { + let mut lines = wrap_plain(text, term.width, style); + lines.push(Line::new()); + lines +} + +/// A paragraph indented by one space, for what a screen adds below its panel. +pub fn note(term: &Term, pieces: &[Piece]) -> Vec<Line> { + let mut rows = reflow(pieces, term.width.saturating_sub(1)); + rows.iter_mut().for_each(|row| row.insert(0, span(" ", PLAIN))); + rows +} + +/// A dim note that ends in a command shown in violet. +pub fn cmd_note(term: &Term, text: &str, command: &str) -> Vec<Line> { + note(term, &[words(DIM, text), whole(DIM_VIOLET, command)]) +} + +/// A centred panel of paragraphs (one style each), then notes with a command, then a blank line. +fn alert( + term: &Term, + border: Style, + heading: &str, + parts: &[(Style, &str)], + tips: &[(&str, &str)], +) -> Vec<Line> { + let title = if heading.is_empty() { vec![] } else { vec![span(heading, BOLD_RED)] }; + let panel = Panel { border, title, pad: (1, 4), center: true }; + let mut body = Vec::new(); + for (style, text) in parts { + if !body.is_empty() { + body.push(Line::new()); + } + body.extend(wrap_plain(text, panel.inner(term.width), *style)); + } + let mut lines = panel.render(body, term.width); + tips.iter().for_each(|(text, command)| lines.extend(cmd_note(term, text, command))); + lines.push(Line::new()); + lines +} + +/// `37s`, `2m 5s`, `1h 2m 3s`. +pub fn duration(seconds: u64) -> String { + match (seconds / 3600, seconds / 60 % 60, seconds % 60) { + (0, 0, s) => format!("{s}s"), + (0, m, s) => format!("{m}m {s}s"), + (h, m, s) => format!("{h}h {m}m {s}s"), + } +} + +pub fn stats(done: &Progress) -> String { + let (time, hints) = (duration(done.seconds()), done.hints); + let attempts = format!(" | Chybné pokusy: {}", done.attempts); + format!("Čas: {time} | Nápovědy: {hints}{}", if done.attempts > 0 { &attempts } else { "" }) +} + +fn markdown_panel(term: &Term, heading: Line, md: &str) -> Vec<Line> { + let panel = Panel { border: CYAN, title: heading, pad: (1, 2), ..Panel::default() }; + panel.render(markdown::render(md, panel.inner(term.width)), term.width) +} + +/// The cyan panel with the assignment. +pub fn level(term: &Term, pos: Pos) -> Vec<Line> { + let level = pos.level(); + let heading = vec![span(format!("{pos}: {}", level.title), BOLD_CYAN)]; + markdown_panel(term, heading, &dedent(level.text)) +} + +/// Page `number` of `total` of a section's intro. +pub fn intro_page( + term: &Term, + section: usize, + page: &str, + number: usize, + total: usize, +) -> Vec<Line> { + let mut heading = format!("Sekce {section}: {}", COURSE[section].title); + if total > 1 { + heading += &format!(" (strana {number}/{total})"); + } + markdown_panel(term, vec![span(heading, BOLD_CYAN)], page) +} + +/// A yellow panel for every one of the first `shown` hints (numbered from 1 of `texts.len()`), then +/// where to ask for the next one, or that there is none. +pub fn hints(term: &Term, texts: &[&str], shown: usize) -> Vec<Line> { + let mut lines = Vec::new(); + for (index, text) in texts[..shown].iter().enumerate() { + let title = vec![span(format!("💡 Nápověda {} z {}", index + 1, texts.len()), YELLOW)]; + let panel = Panel { border: YELLOW, title, pad: (1, 2), ..Panel::default() }; + lines.extend(panel.render(wrap_plain(text, panel.inner(term.width), PLAIN), term.width)); + lines.push(Line::new()); + } + lines.extend(if shown < texts.len() { + let mut tips = cmd_note(term, "Potřebujete další pomoc? Napište: ", "shellgame hint"); + tips.extend(cmd_note(term, "Jen zopakovat odemčené: ", "shellgame hint -r")); + tips + } else { + note(term, &[words(DIM, "Pro tento level již nejsou k dispozici žádné další nápovědy.")]) + }); + lines.push(Line::new()); + lines +} + +pub fn success(term: &Term, message: &str, done: &Progress) -> Vec<Line> { + alert(term, GREEN, "", &[(BOLD_GREEN, message), (DIM, &stats(done))], &[]) +} + +pub fn failure(term: &Term, message: &str) -> Vec<Line> { + let tip = [("Zkuste to znovu nebo použijte: ", "shellgame hint")]; + alert(term, RED, "", &[(BOLD_RED, message)], &tip) +} + +/// The red panel of a blocked `cd`, with the way out below it. +pub fn cd_block(term: &Term, pos: Pos, message: &str) -> Vec<Line> { + let heading = format!("ShellGame ({pos})"); + alert(term, RED, &heading, &[(BOLD_RED, message)], &[(messages::CD_RECOVERY_TIP, "")]) +} + +pub fn error(term: &Term, message: &str) -> Vec<Line> { + alert(term, RED, "", &[(BOLD_RED, message)], &[]) +} + +pub fn setup_error(term: &Term, pos: Pos, why: &str) -> Vec<Line> { + let headline = format!("Level {pos} se nepodařilo připravit."); + let parts = [(BOLD_RED, headline.as_str()), (DIM, why)]; + alert(term, RED, "", &parts, &[("Zkuste: ", "shellgame reset")]) +} + +/// An older saved game was set aside as `file`; `resume` is where it was, when it said. +pub fn retired(term: &Term, file: &str, resume: Option<&str>) -> Vec<Line> { + let headline = "Uložená hra pochází ze starší verze ShellGame a byla odložena."; + let detail = format!("Původní záznam zůstává v souboru {file}."); + let command = format!("shellgame resume {}", resume.unwrap_or("S.L")); + let mut tips = vec![]; + if resume.is_none() { + tips.push(("Najděte svůj level: ", "shellgame levels")); + } + tips.push(("Pokračujte tam, kde jste skončili: ", command.as_str())); + alert(term, YELLOW, "", &[(BOLD_YELLOW, headline), (DIM, &detail)], &tips) +} + +pub fn stale(term: &Term, saved: &str, resolved: Pos) -> Vec<Line> { + let headline = format!("Level {saved} už v této verzi hry neexistuje."); + let detail = format!("Pokračujeme nejbližším dostupným levelem: {resolved}"); + let tip = [("Váš dosavadní postup zůstává zachován: ", "shellgame status")]; + alert(term, YELLOW, "", &[(BOLD_YELLOW, &headline), (DIM, &detail)], &tip) +} + +fn done_levels(state: &State) -> Vec<(&String, &Progress)> { + state.levels.iter().filter(|(_, progress)| progress.done.is_some()).collect() +} + +/// "Dokončené levely: 5", "Celkový čas: 3m 20s", "Nápovědy: 2", "Chybné pokusy: 1" +fn totals(done: &[(&String, &Progress)]) -> [String; 4] { + let sum = |value: fn(&Progress) -> u64| done.iter().map(|(_, p)| value(p)).sum::<u64>(); + let (time, hints, attempts) = + (sum(Progress::seconds), sum(|p| p.hints.into()), sum(|p| p.attempts.into())); + [ + format!("Dokončené levely: {}", done.len()), + format!("Celkový čas: {}", duration(time)), + format!("Nápovědy: {hints}"), + format!("Chybné pokusy: {attempts}"), + ] +} + +pub fn complete(term: &Term, state: &State) -> Vec<Line> { + let summary = totals(&done_levels(state)).join(" | "); + let parts = [(BOLD_GREEN, "Dokončili jste ShellGame!"), (DIM, summary.as_str())]; + let tips = [ + ("Statistiky: ", "shellgame status"), + ("Ukončit hru: ", "shellgame exit"), + ("Zahrát znovu od začátku: ", "shellgame remove"), + ]; + alert(term, GREEN, "", &parts, &tips) +} + +/// "Na jiném počítači pokračujte příkazem: shellgame resume 3.2" +pub fn resume_hint(term: &Term, position: &str) -> Vec<Line> { + let command = format!("shellgame resume {position}"); + cmd_note(term, "Na jiném počítači pokračujte příkazem: ", &command) +} + +/// Every level by section: "▸ zde" marks the saved position, "✓" the completed ones. +pub fn levels(term: &Term, state: Option<&State>) -> Vec<Line> { + let panel = Panel { + border: CYAN, + title: vec![span("Levely", BOLD_CYAN)], + pad: (1, 2), + ..Panel::default() + }; + let current = state.filter(|state| !state.finished).map(|state| state.level.as_str()); + let done = + |pos: &str| state.and_then(|state| state.levels.get(pos)).is_some_and(|p| p.done.is_some()); + let width = |texts: &mut dyn Iterator<Item = usize>| texts.max().unwrap_or(0); + let id_width = width(&mut Pos::all().map(|pos| pos.to_string().len())); + let title_width = width(&mut Pos::all().map(|pos| pos.level().title.chars().count())); + let mut body = Vec::new(); + for (n, section) in COURSE.iter().enumerate() { + if n > 0 { + body.push(Line::new()); + } + body.push(vec![span(format!("Sekce {n}: {}", section.title), BOLD_CYAN)]); + for level in 1..=section.levels.len() { + let (pos, title) = (Pos { section: n, level }, section.levels[level - 1].title); + let id = pos.to_string(); + let mut row = vec![span(format!(" {id:<id_width$} {title:<title_width$}"), PLAIN)]; + if done(&id) { + row.push(span(" ✓", GREEN)); + } + if current == Some(id.as_str()) { + row.push(span(" ▸ zde", BOLD_YELLOW)); + } + body.push(row); + } + } + let mut lines = panel.render(body, term.width); + lines.extend(cmd_note(term, "Pokračovat od zvoleného levelu: ", "shellgame resume 1.4")); + lines.push(Line::new()); + lines +} + +/// A new game: who it is for and where it lives. +pub fn init(term: &Term, user: &str, workspace: &str) -> Vec<Line> { + let who = [words(GREEN, "✓ ShellGame inicializována pro "), whole(GREEN, user)]; + let mut lines = reflow(&who, term.width); + lines.extend(reflow(&[words(DIM, "Pracovní prostor: "), whole(DIM, workspace)], term.width)); + lines.push(Line::new()); + lines +} + +/// One line about the isolation of the game shell, from `SHELLGAME_SANDBOX`; nothing outside it. +pub fn banner(term: &Term, sandbox: Option<&str>) -> Vec<Line> { + let (style, text) = match sandbox { + Some("landlock") => (DIM, "Izolace zapnuta: mimo /tmp nic nesmažete ani nezměníte."), + Some("off") => ( + RED, + "POZOR: herní shell běží bez izolace (--unsafe). Příkazy mohou změnit vaše soubory.", + ), + _ => return Vec::new(), + }; + let mut lines = note(term, &[words(style, text)]); + lines.push(Line::new()); + lines +} + +pub fn teleport(term: &Term, dir: &str) -> Vec<Line> { + let moved = [ + words(YELLOW, "Byli jste "), + words(BOLD_YELLOW, "teleportováni"), + words(YELLOW, " do adresáře "), + whole(VIOLET, dir), + ]; + let mut lines = reflow(&moved, term.width); + lines.push(Line::new()); + lines +} + +/// Rows of cells in aligned columns two spaces apart: the first column left-aligned, the others right. +fn columns(rows: &[Vec<String>]) -> Vec<String> { + let width = |c: usize| rows.iter().map(|row| row[c].chars().count()).max().unwrap_or(0); + let cell = |(c, text): (usize, &String)| match c { + 0 => format!("{text:<w$}", w = width(c)), + _ => format!("{text:>w$}", w = width(c)), + }; + rows.iter().map(|row| row.iter().enumerate().map(cell).collect::<Vec<_>>().join(" ")).collect() +} + +/// The finished levels with their statistics, and where to continue on another computer. +pub fn status(term: &Term, state: &State) -> Vec<Line> { + let title = vec![span("Postup", BOLD_CYAN)]; + let panel = Panel { border: CYAN, title, pad: (1, 2), ..Panel::default() }; + let line = |text: String, style: Style| vec![span(text, style)]; + let mut body = vec![line(format!("Aktuální level: {}", state.level), BOLD), Line::new()]; + let mut done = done_levels(state); + done.sort_by_key(|(key, _)| key.parse::<Pos>().ok()); + if done.is_empty() { + body.push(line("Zatím žádné dokončené levely.".into(), DIM)); + } else { + let cells = |(key, p): &(&String, &Progress)| { + vec![(*key).clone(), duration(p.seconds()), p.hints.to_string(), p.attempts.to_string()] + }; + let mut rows = + vec![["Level", "Čas", "Nápovědy", "Chybné pokusy"].map(String::from).to_vec()]; + rows.extend(done.iter().map(cells)); + let table = columns(&rows).into_iter().enumerate(); + body.extend(table.map(|(n, row)| line(row, if n == 0 { BOLD } else { PLAIN }))); + body.push(Line::new()); + body.extend(totals(&done).map(|total| line(total, PLAIN))); + } + let mut lines = panel.render(body, term.width); + if !state.finished { + lines.extend(resume_hint(term, &state.level)); + } + lines.push(Line::new()); + lines +} + +/// Why the game does not start without Landlock, and what to do instead. +pub fn refusal(term: &Term) -> Vec<Line> { + let headline = "Tento systém nemá Landlock, kterým hra chrání vaše soubory."; + let mut lines = + alert(term, RED, "ShellGame nelze bezpečně spustit", &[(BOLD_RED, headline)], &[]); + let paragraphs = [ + "Hra spouští příkazový řádek, který smí měnit soubory jen v /tmp. K tomu potřebuje Landlock, funkci jádra Linuxu, kterou tento systém nemá nebo nepovoluje.", + "Podporované systémy: Ubuntu 22.04 a novější, Debian 12 a novější, Fedora, Arch Linux, RHEL 9.6 a novější, WSL2.", + "Jinde (například na macOS) můžete hru spustit na vlastní riziko bez izolace: shellgame --unsafe. Příkazy ve hře pak mohou změnit nebo smazat vaše soubory.", + ]; + paragraphs.iter().for_each(|text| lines.extend(say(term, PLAIN, text))); + lines +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ui::{line_width, plain}; + + #[test] + fn every_panel_is_as_wide_as_the_terminal() { + let first = Pos { section: 0, level: 1 }; + for width in [40, 80] { + let term = Term { width, color: false, stderr: false }; + let panels = [ + level(&term, first), + failure(&term, "Chyba, která se musí zalomit na více řádků"), + refusal(&term), + ]; + let framed = + panels.iter().flatten().filter(|line| plain(line).starts_with(['╭', '│', '╰'])); + assert!(framed.map(|line| line_width(line)).all(|w| w == width)); + let body = plain(&panels[0][2]); + assert!(body.starts_with("│ ") && !body.starts_with("│ "), "{body}"); + } + } +} diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/cli.rs b/tests/cli.rs new file mode 100644 index 0000000..c1c8a55 --- /dev/null +++ b/tests/cli.rs @@ -0,0 +1,757 @@ +//! The command line against the real binary, run the way the game shell runs it: with +//! `SHELLGAME_SANDBOX` set, so the directives on stderr show and are decoded here. Every test gets +//! its own world (see support/). Positions come from the course (first strict `cd` level, ...); +//! written down are only where a few sections begin. What the game says is taken from the course +//! (titles, hints, answers), from `messages` or from the screens the binary draws, never retyped. +//! The solutions and strict `cd` levels in the real shells are tests/e2e.rs. + +mod support; + +use std::fs; +use std::os::unix::fs::{PermissionsExt, symlink}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; +use serde_json::{Value, json}; +use shellgame::game::{Pos, dedent, messages, remove_tree, start_dir}; +use shellgame::levels::COURSE; +use shellgame::state::{Progress, State}; +use shellgame::ui::{Line, Term, plain, screens}; +use support::{BIN, Run, World, have, landlock, squash}; + +const FIRST: Pos = Pos { section: 0, level: 1 }; + +/// The binary draws for 80 columns without colour when it has no terminal, as here. +const TERM: Term = Term { width: 80, color: false, stderr: false }; + +fn at(section: usize, level: usize) -> Pos { + Pos { section, level } +} + +/// The first level that sets files up. +fn with_files() -> Pos { + Pos::all().find(|pos| !pos.level().setup.is_empty()).unwrap() +} + +fn heading(pos: Pos) -> String { + format!("{pos}: {}", pos.level().title) +} + +fn section_title(n: usize) -> String { + format!("Sekce {n}: {}", COURSE[n].title) +} + +/// The answer that completes the first level: what its solution submits, a plain word. +fn first_answer() -> String { + let solution = dedent(FIRST.level().solution); + let answer = solution.lines().last().and_then(|line| line.strip_prefix("shellgame submit ")); + let answer = answer.expect("the first solution submits an answer").trim_matches(['"', '\'']); + assert!(!answer.contains(['$', ' ']), "the first answer is a plain word: {answer:?}"); + answer.to_owned() +} + +/// The text of a screen as the binary prints it. +fn screen(lines: Vec<Line>) -> String { + lines.iter().map(|line| plain(line)).collect::<Vec<_>>().join("\n") +} + +/// The pages of a section's intro: separated by a line `---`. +fn pages(intro: &str) -> usize { + dedent(intro).lines().filter(|line| line.trim() == "---").count() + 1 +} + +fn mode(path: &Path) -> u32 { + fs::symlink_metadata(path).unwrap().permissions().mode() & 0o7777 +} + +fn chmod(path: &Path, mode: u32) { + fs::set_permissions(path, fs::Permissions::from_mode(mode)).unwrap(); +} + +impl Run { + #[track_caller] + fn code(&self, code: i32) -> &Run { + assert_eq!(self.code, code, "{self:#?}"); + self + } + + /// `text` appears in stdout, ignoring escapes, box drawing and line folds. + #[track_caller] + fn says(&self, text: &str) -> &Run { + assert!(squash(&self.out).contains(&squash(text)), "no {text:?} in {self:#?}"); + self + } + + #[track_caller] + fn says_not(&self, text: &str) -> &Run { + assert!(!squash(&self.out).contains(&squash(text)), "{text:?} in {self:#?}"); + self + } + + /// The directives on stderr, decoded: `verb argument ...`. + fn directives(&self) -> Vec<String> { + let decode = |word: &str| String::from_utf8(STANDARD.decode(word).unwrap()).unwrap(); + let lines = self.err.lines().filter_map(|l| l.strip_prefix("__SHELLGAME_EXEC__v1 ")); + let join = |line: &str| -> String { + let words = line.split(' ').enumerate(); + words + .map(|(i, word)| if i == 0 { word.into() } else { decode(word) }) + .collect::<Vec<_>>() + .join(" ") + }; + lines.map(join).collect() + } + + fn moved_to(&self, dir: &Path) -> bool { + self.directives().contains(&format!("cd {}", dir.display())) + } +} + +impl World { + /// A game whose intro and level 0.1 were shown. + fn started() -> World { + let game = World::new(); + game.run(&[]).code(0); + game + } + + fn run_with(&self, args: &[&str], stdin: &str) -> Run { + self.exec(self.command(BIN).args(args), stdin) + } + + /// Runs a command that must succeed and say `text`. + #[track_caller] + fn ok(&self, args: &[&str], text: &str) -> Run { + let run = self.run(args); + run.code(0).says(text); + run + } + + /// Runs commands that must end as expected: (arguments, status, a text on stdout). + #[track_caller] + fn expect(&self, cases: &[(&[&str], i32, &str)]) { + for (args, code, text) in cases { + self.run(args).code(*code).says(text); + } + } + + fn resume(&self, pos: Pos) -> Run { + self.ok(&["resume", &pos.to_string()], &heading(pos)) + } + + fn state(&self) -> Value { + serde_json::from_slice(&self.raw_state()).unwrap() + } + + fn raw_state(&self) -> Vec<u8> { + fs::read(self.state_file()).unwrap() + } + + fn state_file(&self) -> PathBuf { + self.state.join("state.json") + } + + fn put_state(&self, state: &Value) { + fs::create_dir_all(&self.state).unwrap(); + fs::write(self.state_file(), state.to_string()).unwrap(); + } + + /// The saved game with this current level and nothing completed. + fn put_level(&self, level: &str, finished: bool) { + self.put_state(&json!({"version": 3, "workspace": self.ws, "level": level, "finished": finished, "levels": {}})); + } + + /// The saved progress of a level. + fn progress(&self, pos: Pos) -> Progress { + serde_json::from_value(self.state()["levels"][pos.to_string()].clone()).unwrap() + } +} + +/// Every path below a directory. +fn tree(dir: &Path) -> Vec<PathBuf> { + let mut found = Vec::new(); + for entry in fs::read_dir(dir).unwrap().flatten() { + found.push(entry.path()); + if entry.file_type().unwrap().is_dir() { + found.extend(tree(&entry.path())); + } + } + found.sort(); + found +} + +/// An executable script from an indented block of lines (see `dedent`). +fn write_script(path: &Path, text: &str) { + fs::write(path, dedent(text) + "\n").unwrap(); + fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap(); +} + +#[test] +fn the_first_run_starts_a_game_with_the_intro_and_the_first_level() { + let g = World::new(); + let r = g.ok(&[], §ion_title(0)); + r.says(&heading(FIRST)); + let (state, ws) = (g.state(), g.ws.display()); + assert_eq!([&state["version"], &state["level"]], [&json!(3), &json!("0.1")]); + assert!(state["levels"].as_object().unwrap().keys().eq(["0.1"])); + let expected = [format!("export SHELLGAME_WORKSPACE {ws}"), format!("cd {ws}/level-0")]; + assert_eq!(r.directives(), expected); + assert_eq!(r.err.lines().count(), 2, "nothing but the directives"); + // Back at the level, nothing is moved and no intro is shown again. + let again = g.exec(g.command(BIN).current_dir(start_dir(FIRST, &g.ws)), ""); + again.code(0).says(&heading(FIRST)).says_not(§ion_title(0)); + assert!(again.directives().iter().all(|directive| !directive.starts_with("cd "))); +} + +#[test] +fn answers_are_counted_and_a_right_one_moves_on_into_the_next_section() { + let g = World::started(); + let reply = FIRST.level().answer.judge(Some("wrong")).unwrap_err(); + // Several words are one answer, a dash is not an option, a missing answer is asked for. + #[rustfmt::skip] + g.expect(&[ + (&["submit", "wrong"], 0, &reply), (&["submit", "-5", "a", "b"], 0, &reply), + (&["submit"], 0, messages::ANSWER_REQUIRED), (&["hint"], 0, FIRST.level().hints[0]), + ]); + assert!(g.state()["levels"]["0.1"]["done"].is_null()); + let r = g.ok(&["submit", &first_answer()], FIRST.level().success); + let done = g.progress(FIRST); + assert_eq!((done.attempts, done.hints), (3, 1), "every wrong or missing answer counts"); + r.says(&screens::stats(&done)); + r.says(§ion_title(1)).says(&heading(at(1, 1))); + let state = g.state(); + assert_eq!(state["level"], "1.1"); + assert!(state["levels"]["0.1"]["done"].is_u64() && state["levels"]["1.1"]["done"].is_null()); + g.ok(&["status"], "shellgame resume 1.1"); +} + +#[test] +fn every_hint_shows_the_unlocked_ones_and_one_more_until_none_is_left() { + let g = World::started(); + let hints = FIRST.level().hints; + let shown = + |r: &Run| hints.iter().filter(|hint| squash(&r.out).contains(&squash(hint))).count(); + let saved = || g.state()["levels"]["0.1"]["hints"].clone(); + // Repeating before the first hint unlocks nothing and shows none. + let r = g.run(&["hint", "-r"]); + assert_eq!((r.code, shown(&r), saved()), (0, 0, json!(0)), "{r:#?}"); + for n in 1..=hints.len() { + let r = g.ok(&["hint"], hints[n - 1]); + assert_eq!(shown(&r), n, "{r:#?}"); + // `--repeat` shows the same hints again without unlocking another. + let again = g.run(&["hint", "--repeat"]); + assert_eq!((again.code, shown(&again), saved()), (0, n, json!(n)), "{again:#?}"); + } + let r = g.run(&["hint"]); + assert_eq!((r.code, shown(&r), saved()), (0, hints.len(), json!(hints.len())), "{r:#?}"); +} + +#[test] +fn reset_rebuilds_the_level_and_brings_the_player_back() { + let (g, pos) = (World::new(), with_files()); + g.resume(pos); + let root = pos.section_dir(&g.ws); + let before = tree(&root); + assert!(before.len() > 1); + for entry in fs::read_dir(&root).unwrap().flatten() { + let is_dir = entry.file_type().unwrap().is_dir(); + let gone = + if is_dir { fs::remove_dir_all(entry.path()) } else { fs::remove_file(entry.path()) }; + gone.unwrap(); + } + let r = g.ok(&["reset"], &heading(pos)); + assert!(r.moved_to(&start_dir(pos, &g.ws))); + assert_eq!(tree(&root), before); +} + +/// What a player can do to the directories of the game: (what, damage to the workspace and the +/// section directory, given a directory outside the game). +type Damage = (&'static str, fn(&Path, &Path, &Path)); + +/// Whatever the player did to the workspace or the section directory, `reset` rebuilds the level, +/// leaves both usable and brings the player to the start. A link is removed, never followed. +#[test] +fn reset_repairs_the_workspace_and_the_section_directory() { + let pos = with_files(); + let damages: [Damage; 5] = [ + ("chmod 000 on the workspace", |ws, _, _| chmod(ws, 0o000)), + ("chmod a-x on the workspace", |ws, _, _| chmod(ws, mode(ws) & !0o111)), + ("a file in place of the workspace", |ws, _, _| { + remove_tree(ws).unwrap(); + fs::write(ws, "a file").unwrap(); + }), + ("a link to elsewhere in place of the section directory", |_, section, outside| { + remove_tree(section).unwrap(); + symlink(outside, section).unwrap(); + }), + ("chmod 000 on the section directory", |_, section, _| chmod(section, 0o000)), + ]; + for (what, damage) in damages { + let g = World::new(); + g.resume(pos); + let (section, outside) = (pos.section_dir(&g.ws), g.out.join("outside")); + let before = tree(§ion); + fs::create_dir(&outside).unwrap(); + fs::write(outside.join("keep"), "keep").unwrap(); + + damage(&g.ws, §ion, &outside); + let r = g.run(&["reset"]); + + r.code(0).says(&heading(pos)); + assert!(r.moved_to(&start_dir(pos, &g.ws)), "{what}: {r:#?}"); + for dir in [&g.ws, §ion] { + assert!(!dir.is_symlink() && mode(dir) & 0o700 == 0o700, "{what}: {dir:?}"); + } + assert_eq!(tree(§ion), before, "{what}"); + assert_eq!(tree(&outside), [outside.join("keep")], "{what}: the link was followed"); + assert_eq!(fs::read_to_string(outside.join("keep")).unwrap(), "keep", "{what}"); + } +} + +#[test] +fn show_shows_an_assignment_or_an_intro_without_touching_the_game() { + let g = World::started(); + let before = g.raw_state(); + let (first, second, third) = (heading(FIRST), heading(at(1, 2)), section_title(2)); + // `repeat` is another name for `show`. + #[rustfmt::skip] + g.expect(&[ + (&["show"], 0, &first), (&["show", "0.1"], 0, &first), (&["show", "1.2"], 0, &second), + (&["show", "2"], 0, &third), (&["show", "0"], 0, §ion_title(0)), + (&["repeat"], 0, &first), (&["repeat", "1.2"], 0, &second), + (&["show", "1.99"], 1, "shellgame levels"), (&["show", "99"], 1, "shellgame levels"), + (&["show", "x"], 1, "shellgame levels"), + ]); + assert_eq!(g.raw_state(), before); +} + +#[test] +fn resume_continues_on_another_computer() { + let g = World::new(); + let r = g.resume(at(3, 2)); + r.says_not(§ion_title(3)); + assert!(r.moved_to(&start_dir(at(3, 2), &g.ws))); + assert!(g.state()["levels"].as_object().unwrap().keys().eq(["3.2"]), "nothing else completed"); + g.ok(&["status"], &screen(screens::resume_hint(&TERM, "3.2"))); + // A section starts with its intro and its first level. + g.ok(&["resume", "3"], §ion_title(3)).says(&heading(at(3, 1))); + // Without a target the levels are listed; a wrong one leads to them. + let row = format!("3.1 {}", at(3, 1).level().title); + g.run(&["resume"]).code(1).says(§ion_title(0)).says(&row); + for target in ["99", "1.99", "x", ""] { + g.run(&["resume", target]).code(1).says("shellgame levels"); + } + assert_eq!(g.state()["level"], "3.1"); + g.ok(&["exit"], "shellgame resume 3.1"); +} + +#[test] +fn resume_keeps_every_record_and_plays_a_finished_game_on() { + let g = World::started(); + g.ok(&["submit", &first_answer()], FIRST.level().success); + let done = g.state()["levels"]["0.1"].clone(); + assert!(done["done"].is_u64()); + g.resume(FIRST); + assert_eq!(g.state()["levels"]["0.1"], done); + assert_eq!(g.state()["level"], "0.1"); + g.put_state(&json!({"version": 3, "workspace": g.ws, "level": "0.1", "finished": true, "levels": {"0.1": done}})); + g.resume(at(1, 1)); + let state = g.state(); + assert_eq!([&state["finished"], &state["level"]], [&json!(false), &json!("1.1")]); + assert_eq!(state["levels"]["0.1"], done); + assert!(state["levels"]["1.1"]["started"].as_u64() > Some(0)); +} + +#[test] +fn levels_lists_every_level_marking_the_saved_and_the_completed_ones() { + let g = World::new(); + let r = g.ok(&["levels"], "shellgame resume"); + assert!(!g.state_file().exists(), "listing starts no game"); + let rows = |r: &Run| -> Vec<String> { + r.out.lines().map(squash).filter(|line| !line.is_empty()).collect() + }; + // Every section and level, in course order; no marks without a game. + let mut expected = Vec::new(); + for (n, section) in COURSE.iter().enumerate() { + expected.push(section_title(n)); + let row = |level: usize| format!("{} {}", at(n, level), section.levels[level - 1].title); + expected.extend((1..=section.levels.len()).map(row)); + } + let shown = rows(&r); + let found: Vec<_> = + expected.iter().map(|text| shown.iter().position(|line| line == text)).collect(); + assert!( + found.windows(2).all(|pair| pair[0] < pair[1]) && found[0].is_some(), + "{expected:?}\n{r:#?}" + ); + r.says_not("▸").says_not("✓"); + // Marks follow the saved game; the section heading is not a row. + let done = json!({"started": 100, "done": 160, "attempts": 0, "hints": 0}); + let levels = json!({"0.1": done, "1.1": done, "1.2": {"started": 100, "done": null, "attempts": 0, "hints": 0}}); + g.put_state(&json!({"version": 3, "workspace": g.ws, "level": "1.2", "finished": false, "levels": levels})); + let marked = rows(&g.ok(&["levels"], "shellgame resume")); + let mark = |pos: Pos| marked.iter().find(|line| line.starts_with(&format!("{pos} "))).unwrap(); + assert!(mark(FIRST).ends_with('✓') && mark(at(1, 1)).ends_with('✓')); + assert!(mark(at(1, 2)).contains('▸') && !mark(at(1, 2)).contains('✓')); + assert!(!mark(at(1, 3)).contains(['✓', '▸'])); +} + +#[test] +fn remove_deletes_the_workspace_and_the_state_after_asking() { + let g = World::started(); + for answer in ["", "n\n"] { + g.run_with(&["remove"], answer).code(1); + assert!(g.ws.is_dir() && g.state_file().is_file(), "declining deletes nothing"); + } + let r = g.run_with(&["remove"], "y\n"); + r.code(0); + assert!(!g.ws.exists() && !g.state_file().exists() && r.directives() == ["exit"]); + g.run(&["remove", "--yes"]).code(1); + // It works on a damaged state too, which nothing else repairs, and never leaves /tmp. + g.run(&[]).code(0); + fs::write(g.state_file(), "{ not json").unwrap(); + g.run(&[]).code(1); + assert_eq!(g.raw_state(), b"{ not json"); + g.run(&["remove", "--yes"]).code(0); + assert!(!g.ws.exists() && !g.state_file().exists()); + g.put_state(&json!({"version": 3, "workspace": "/etc/sg", "level": "0.1", "finished": false, "levels": {}})); + g.run(&["remove", "--yes"]).code(1); + assert!(g.state_file().exists()); +} + +#[test] +fn a_saved_game_of_an_older_version_is_set_aside_and_of_a_newer_one_refused() { + let g = World::new(); + // A Python save names its level, so the notice, at the end of the output, says how to get back + // there; other old saves point to the list of levels. + let saves = [ + (json!({"version": "2.1", "workspace": g.ws, "current_level": "1.4"}), Some("1.4")), + (json!({"version": "2.1", "workspace": g.ws, "current_level": "5.0"}), Some("5")), + (json!({"version": "2.0", "workspace": g.ws}), None), + ]; + for (n, (old, resume)) in saves.into_iter().enumerate() { + g.put_state(&old); + let r = g.ok(&[], &heading(FIRST)); + let name = + if n == 0 { "state-old.json".into() } else { format!("state-old-{}.json", n + 1) }; + let file = g.state.join(name); + assert_eq!(fs::read_to_string(&file).unwrap(), old.to_string()); + assert_eq!(g.state()["version"], 3); + let notice = squash(&screen(screens::retired(&TERM, &file.display().to_string(), resume))); + assert!(squash(&r.out).ends_with(¬ice), "not last: {notice:?} in {r:#?}"); + } + // A save of a newer version is refused and left as it is. + let newer = + json!({"version": 4, "workspace": g.ws, "level": "0.1", "finished": false, "levels": {}}); + g.put_state(&newer); + for args in [&[][..], &["status"], &["hint"]] { + g.run(args).code(1).says("novější verze").says("shellgame update"); + assert_eq!(g.raw_state(), newer.to_string().into_bytes()); + } + assert!(!g.state.join("state-old-4.json").exists(), "a newer save is not set aside"); +} + +#[test] +fn a_finished_game_is_inert() { + let g = World::started(); + let done = json!({"started": 100, "done": 160, "attempts": 0, "hints": 0}); + let last = Pos::all().last().unwrap().to_string(); + let finished = json!({"version": 3, "workspace": g.ws, "level": last, "finished": true, "levels": {"0.1": done}}); + g.put_state(&finished); + let complete = + screen(screens::complete(&TERM, &serde_json::from_value::<State>(finished).unwrap())); + let before = g.raw_state(); + for args in [&["hint"][..], &["submit", "x"], &["reset"], &[], &["show"]] { + g.ok(args, &complete).says(&screens::duration(60)); + } + assert_eq!(g.raw_state(), before); + g.ok(&["show", "1.2"], &heading(at(1, 2))).says_not(&complete); + assert_eq!(g.raw_state(), before); + g.resume(at(1, 1)); + assert_eq!(g.state()["finished"], false, "resuming plays on"); +} + +#[test] +fn a_position_that_no_longer_exists_resumes_at_the_nearest_following_level() { + let g = World::started(); + for (saved, resumed) in [("1.99", "2.1"), ("garbage", "0.1")] { + let resumed: Pos = resumed.parse().unwrap(); + g.put_level(saved, false); + let r = g.ok(&["hint"], &screen(screens::stale(&TERM, saved, resumed))); + r.says(&heading(resumed)); + assert_eq!(g.state()["level"], resumed.to_string()); + assert_eq!(g.progress(resumed).hints, 0, "the command was not applied to it"); + } +} + +#[test] +fn a_cleared_workspace_is_rebuilt() { + let (g, pos) = (World::new(), with_files()); + g.resume(pos); + let before = tree(&g.ws); + assert!(before.len() > 1); + fs::remove_dir_all(&g.ws).unwrap(); + g.ok(&["hint"], pos.level().hints[0]); + assert_eq!(tree(&g.ws), before); +} + +#[test] +fn a_link_in_place_of_the_next_section_is_replaced_and_never_followed() { + let g = World::started(); + let (next, outside) = (FIRST.next().unwrap(), g.out.join("outside")); + assert_ne!(next.section, FIRST.section, "the first section has one level"); + fs::create_dir(&outside).unwrap(); + symlink(&outside, next.section_dir(&g.ws)).unwrap(); + let r = g.ok(&["submit", &first_answer()], FIRST.level().success); + r.says(&heading(next)); + assert!(r.moved_to(&start_dir(next, &g.ws))); + let state = g.state(); + assert!(state["levels"]["0.1"]["done"].is_u64() && state["level"] == next.to_string()); + assert!(!next.section_dir(&g.ws).is_symlink() && start_dir(next, &g.ws).is_dir()); + assert_eq!(fs::read_dir(&outside).unwrap().count(), 0, "nothing was written through it"); +} + +#[test] +fn the_hooks_fail_open() { + // Malformed calls and a missing game block nothing: a broken hook never locks the player in. + let g = World::new(); + #[rustfmt::skip] + g.expect(&[ + (&["cd-hook"], 0, ""), (&["cd-hook", "..", "/w"], 0, ""), (&["cd-hook", "pre", "/w"], 0, ""), + (&["cd-hook", "pre", "/w", "", "--", "/etc"], 0, ""), + ]); +} + +#[test] +fn a_mistyped_command_names_what_was_typed_and_what_was_meant() { + let g = World::new(); + let r = g.run(&["sumbit", "x"]); + assert!(r.code == 2 && r.err.contains("'sumbit'") && r.err.contains("'submit'"), "{r:#?}"); + let r = g.run(&["--shell", "zsh"]); + assert!(r.code == 2 && r.err.contains("'zsh'") && r.err.contains("bash, fish"), "{r:#?}"); +} + +#[test] +fn update_replaces_the_binary_with_the_downloaded_release() { + if !have("curl") || !have("tar") { + return eprintln!("SKIPPED: curl and tar are needed"); + } + let mut g = World::new(); + let (release, copy, archive) = + (g.tools.join("release"), g.tools.join("shellgame"), g.root.join("release.tar.gz")); + fs::create_dir(&release).unwrap(); + let script = r#" + #!/bin/sh + echo 'shellgame 9.9.9 (test)' + "#; + write_script(&release.join("shellgame"), script); + let tar = Command::new("tar") + .arg("-czf") + .arg(&archive) + .arg("-C") + .arg(&release) + .arg("shellgame") + .status(); + assert!(tar.unwrap().success()); + fs::copy(BIN, ©).unwrap(); + g.set("SHELLGAME_UPDATE_URL", format!("file://{}", archive.display())); + let run = |g: &World, args: &[&str]| g.exec(g.command(©).args(args), ""); + let original = run(&g, &["--version"]).out; + // Inside the sandbox the binary cannot be replaced, so it does not try. + g.set("SHELLGAME_SANDBOX", "landlock"); + run(&g, &["update"]).code(1).says("shellgame update"); + assert_eq!(run(&g, &["--version"]).out, original); + g.unset("SHELLGAME_SANDBOX"); + run(&g, &["update"]).code(0).says("9.9.9"); + assert_eq!(run(&g, &["--version"]).out, "shellgame 9.9.9 (test)\n"); + // A failed download changes nothing; no scratch directory is left next to the binary. + g.set("SHELLGAME_UPDATE_URL", "file:///nonexistent.tar.gz"); + let again = g.tools.join("again"); + fs::copy(BIN, &again).unwrap(); + g.exec(g.command(&again).arg("update"), "").code(1); + assert_eq!(fs::read(&again).unwrap(), fs::read(BIN).unwrap()); + let names: Vec<_> = fs::read_dir(&g.tools).unwrap().map(|e| e.unwrap().file_name()).collect(); + assert!(names.iter().all(|name| !name.to_string_lossy().starts_with('.')), "{names:?}"); +} + +#[test] +fn the_version_works_anywhere_and_outside_the_game_shell_nothing_is_replayed() { + let mut g = World::new(); + g.unset("SHELLGAME_SANDBOX"); + let version = format!("shellgame {} (", env!("CARGO_PKG_VERSION")); + assert!(g.run(&["--version"]).code(0).out.starts_with(&version)); + assert!(g.run(&["-V"]).code(0).out.starts_with(&version)); + assert!(g.run(&["levels"]).code(0).err.is_empty()); + g.run(&["exit"]).code(1); + g.run(&["status"]).code(1); + assert!(!g.state_file().exists(), "`status` starts no game"); + g.put_level("0.1", false); + assert!(g.run_with(&["remove"], "y\n").code(0).err.is_empty()); +} + +#[test] +fn a_workspace_outside_tmp_and_a_state_directory_above_home_are_refused() { + let mut g = World::new(); + symlink("/usr", g.root.join("link")).unwrap(); + for workspace in [g.root.join("link/ws"), "/var/sg-test".into(), "/tmp".into()] { + g.set("SHELLGAME_WORKSPACE", workspace); + g.run(&[]).code(1); + assert!(!g.state.exists()); + } + for state_dir in [g.home.clone(), g.out.clone()] { + g.set("SHELLGAME_STATE_DIR", &state_dir).unset("SHELLGAME_WORKSPACE"); + g.run(&[]).code(1); + assert!(!state_dir.join("state.json").exists()); + } +} + +#[test] +fn outside_the_game_shell_the_bare_command_starts_it_confined_unconfined_or_not_at_all() { + let mut g = World::new(); + let (fakes, log) = (g.tools.join("fakes"), g.root.join("shell.log")); + fs::create_dir(&fakes).unwrap(); + let script = format!( + r#" + #!/bin/sh + env > {} + exit 7 + "#, + log.display() + ); + write_script(&fakes.join("bash"), &script); + g.unset("SHELLGAME_SANDBOX"); + g.set("PATH", format!("{}:{}", fakes.display(), std::env::var("PATH").unwrap())); + // `--unsafe` starts it as it is; the shell's own exit status is not an error. + g.run(&["--shell", "bash", "--unsafe"]).code(0); + assert!(fs::read_to_string(&log).unwrap().contains("SHELLGAME_SANDBOX=off")); + // Without it the shell runs confined where Landlock works, and does not run elsewhere. + fs::remove_file(&log).unwrap(); + if landlock() { + g.run(&["--shell", "bash"]).code(0); + assert!(fs::read_to_string(&log).unwrap().contains("SHELLGAME_SANDBOX=landlock")); + } else { + g.expect(&[(&["--shell", "bash"], 1, "Landlock")]); + assert!(!log.exists()); + } +} + +#[test] +fn outside_the_game_shell_resume_runs_in_a_new_game_shell_and_listings_start_none() { + let mut g = World::new(); + g.unset("SHELLGAME_SANDBOX"); + let (fakes, log) = (g.tools.join("fakes"), g.root.join("shell.log")); + fs::create_dir(&fakes).unwrap(); + write_script(&fakes.join("bash"), &format!("#!/bin/sh\ntouch {}\n", log.display())); + let path = format!("{}:{}", fakes.display(), std::env::var("PATH").unwrap()); + // Print-only commands never start a shell, `resume` with a level does. + g.set("PATH", path); + g.expect(&[ + (&["levels"], 0, "shellgame resume"), + (&["resume"], 1, §ion_title(0)), + (&["resume", "99"], 1, "shellgame levels"), + ]); + assert!(!log.exists()); + g.run(&["--shell", "bash", "--unsafe", "resume", "3.2"]); + assert!(log.exists()); + // In the real shells the player ends up at the level, saved, and the command runs only once. + let (pos, here) = (at(3, 2), g.root.join("here")); + for shell in ["bash", "fish"].into_iter().filter(|shell| have(shell)) { + g.set("PATH", std::env::var("PATH").unwrap()); + let g = &g; + let _ = fs::remove_file(g.state_file()); + let typed = format!( + "pwd > '{}'; echo \"[$SHELLGAME_PENDING]\" >> '{}'\nexit\n", + here.display(), + here.display() + ); + let args = ["--shell", shell, "--unsafe", "resume", &pos.to_string()]; + let run = g.exec(g.command(BIN).args(args), &typed); + run.code(0).says(&heading(pos)); + let written = fs::read_to_string(&here).unwrap(); + assert_eq!( + written, + format!("{}\n[]\n", start_dir(pos, &g.ws).display()), + "{shell}: {run:?}" + ); + assert_eq!(g.state()["level"], "3.2"); + } +} + +/// Runs a binary on a terminal and answers each prompt with the next key. A prompt is the program +/// waiting in non-canonical mode (as `pause` does) after printing something new since the last key. +/// Prints what the terminal showed, the offsets in it where keys were sent, and the status. +const PTY_HELPER: &str = r#" +import fcntl, json, os, pty, select, struct, sys, termios, time +binary, cwd, keys, *args = sys.argv[1:] +keys = [key.encode() for key in json.loads(keys)] +pid, fd = pty.fork() +if pid == 0: + fcntl.ioctl(0, termios.TIOCSWINSZ, struct.pack("HHHH", 40, 100, 0, 0)) + os.chdir(cwd) + os.execv(binary, [binary] + args) + +def waits(): + try: + return not termios.tcgetattr(fd)[3] & termios.ICANON + except termios.error: + return False + +out, sent, fresh, deadline = b"", [], True, time.time() + 30 +while time.time() < deadline: + if select.select([fd], [], [], 0.05)[0]: + try: + data = os.read(fd, 65536) + except OSError: + break + if not data: + break + out, fresh = out + data, True + elif keys and fresh and waits(): + sent.append(len(out)) + os.write(fd, keys.pop(0)) + fresh = False +for _ in range(500): + done, status = os.waitpid(pid, os.WNOHANG) + if done: + break + time.sleep(0.01) +else: + os.kill(pid, 9) + _, status = os.waitpid(pid, 0) +code = os.waitstatus_to_exitcode(status) +sys.stdout.buffer.write(out + ("\nKEYS:%s\nSTATUS:%d" % (json.dumps(sent), code)).encode()) +"#; + +/// What the program showed on a terminal and at which offsets of it each key was sent. +fn in_pty(g: &World, args: &[&str], keys: &[&str]) -> Option<(Run, Vec<usize>)> { + if !have("python3") { + eprintln!("SKIPPED: python3 is needed for a terminal"); + return None; + } + let mut python = g.command("python3"); + python.args(["-I", "-c", PTY_HELPER, BIN]).arg(&g.cwd).arg(json!(keys).to_string()).args(args); + let text = g.exec(&mut python, "").out; + let (rest, status) = text.rsplit_once("\nSTATUS:").unwrap_or_else(|| panic!("{text}")); + let (shown, sent) = rest.rsplit_once("\nKEYS:").unwrap_or_else(|| panic!("{text}")); + let run = Run { code: status.trim().parse().unwrap(), out: shown.into(), err: shown.into() }; + Some((run, serde_json::from_str(sent).unwrap())) +} + +#[test] +fn enter_pages_through_an_intro_and_ctrl_c_stops_it_without_losing_anything() { + let Some((run, sent)) = in_pty(&World::new(), &[], &["\n"; 8]) else { return }; + run.code(0).says(§ion_title(0)).says(&heading(FIRST)); + assert!(!sent.is_empty(), "the intro waited for Enter: {run:#?}"); + // Ctrl-C at the first prompt of an intro with more pages ends the paging, not the command. + let n = COURSE.iter().position(|s| pages(s.intro) > 1).expect("an intro with several pages"); + let g = World::new(); + let Some((run, sent)) = in_pty(&g, &["resume", &n.to_string()], &["\x03"]) else { return }; + run.code(0).says(&heading(at(n, 1))); + assert_eq!(sent.len(), 1, "one prompt, then no more pages: {run:#?}"); + // The directives were emitted before the prompt, so Ctrl-C could not lose them. + let at: Vec<_> = run.err.match_indices("__SHELLGAME_EXEC__").map(|(at, _)| at).collect(); + assert!(!at.is_empty() && at.iter().all(|at| *at < sent[0]), "{run:?}"); + assert_eq!(g.state()["level"], format!("{n}.1")); +} diff --git a/tests/e2e.rs b/tests/e2e.rs new file mode 100644 index 0000000..641319a --- /dev/null +++ b/tests/e2e.rs @@ -0,0 +1,545 @@ +//! The course against the real game shell: the real binary, integration script and hooks in ONE bash +//! or fish process per trial, confined by Landlock where the kernel has it (so no level may need to +//! write outside /tmp). One trial per level and shell, named like `solutions::3.2::bash`; run one +//! with `cargo test --test e2e -- solutions::3.2::bash`. +//! +//! * `solutions`: `shellgame resume`, an empty submit must not complete the level, its solution must, +//! then the same again over the dirty workspace (what `reset` and `resume` meet). Each line of the +//! solution is a step of its own: it may fail, but never with 126 or 127 (a command that is not +//! there or cannot run). bash runs under `umask 077`. +//! * `playthrough`: a fresh game, every solution in course order, no resumes. +//! * `strict_cd`: levels with a `cd` policy never lock the player in: a wrong move is refused, the +//! way back to the start is always open, the drill frees every move and `reset` restores the rule. +//! * `regression`: the traps found in single levels, and a player who deletes the directory they +//! stand in or takes the `x` away from the one above it. Each finds its level in the course; a +//! level it cannot find fails that trial only. +//! * `harness`: the replay itself fails a wrong solution. +//! +//! fish is optional; below fish 3.4 a trial whose solution uses `$(...)` is skipped with a note. + +mod support; + +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::OnceLock; + +use libtest_mimic::{Arguments, Completion, Failed, Trial}; +use shellgame::game::messages::{CD_RECOVERY_TIP, CWD_MISSING}; +use shellgame::game::{Ctx, Pos, dedent, prepare, start_dir}; +use shellgame::shell::ShellKind::{self, Bash, Fish}; +use support::{ShellRun, Step, World, landlock, squash}; + +/// What a step of a scene must do. Everything but `Blocked` and `Runs` ends with status 0; a game +/// command (`shellgame ...`) must not write to stderr, where the player would see it. +enum Want { + Ok, + /// A line of a solution: it may fail (some do on purpose), but its command must exist and run. + Runs, + /// And the player stands in this directory afterwards. + At(PathBuf), + /// The `cd` is refused: status 1, the recovery tip and this text on stderr, the player stays. + Blocked(&'static str), + /// The level is (not) done in the saved game afterwards. + Done(bool), + /// This text is in what the step printed. + Says(&'static str), + /// This text is not in what the step printed. + Lacks(&'static str), +} + +type Scene = Vec<(String, Want)>; +/// A regression scene for the level at `pos`, built in a world. +type Build = fn(&World, ShellKind, Pos) -> Scene; +/// Picks the level of a regression scene from the course. +type Find = fn() -> Result<Pos, String>; + +impl Step { + fn done(&self, pos: Pos) -> bool { + !self.state["levels"][pos.to_string()]["done"].is_null() + } +} + +impl ShellRun { + /// The run itself was sound: the shell exited normally after the last step and printed nothing of + /// its own, no protocol line or panic reached the player, every step left the shell in a + /// directory that exists (a `reset` that rebuilds the player's directory must move them) and + /// there was a saved game throughout. + fn check(&self) -> Result<(), String> { + if !self.finished || self.code != 0 || !self.noise.is_empty() { + let at = + self.steps.iter().position(|step| step.status < 0).unwrap_or(self.steps.len() - 1); + return Err(self.report( + at, + &format!( + "the shell stopped early or printed itself: {} {:?}", + self.code, self.noise + ), + )); + } + for (n, step) in self.steps.iter().enumerate() { + let why = if (step.out.clone() + &step.err).contains("__SHELLGAME_EXEC__") { + "a protocol line leaked to the player" + } else if step.err.contains("panicked at") { + "the game panicked" + } else if !step.alive { + "the shell was left in a deleted directory" + } else if step.state.is_null() { + "there is no saved game" + } else { + continue; + }; + return Err(self.report(n, why)); + } + Ok(()) + } + + /// `why`, and what step `n` did. + fn report(&self, n: usize, why: &str) -> String { + let Step { cmd, status, pwd, out, err, .. } = &self.steps[n]; + format!( + "{why}\n[{n}] {cmd}\n status {status}, in {}\n stdout: {}\n stderr: {}", + pwd.display(), + squash(out), + squash(err) + ) + } +} + +fn ok(cmd: impl Into<String>) -> (String, Want) { + (cmd.into(), Want::Ok) +} + +fn runs(cmd: impl Into<String>) -> (String, Want) { + (cmd.into(), Want::Runs) +} + +fn at(cmd: impl Into<String>, dir: &Path) -> (String, Want) { + (cmd.into(), Want::At(dir.to_owned())) +} + +fn blocked(cmd: impl Into<String>, text: &'static str) -> (String, Want) { + (cmd.into(), Want::Blocked(text)) +} + +fn done(cmd: impl Into<String>, done: bool) -> (String, Want) { + (cmd.into(), Want::Done(done)) +} + +fn says(cmd: impl Into<String>, text: &'static str) -> (String, Want) { + (cmd.into(), Want::Says(text)) +} + +fn lacks(cmd: impl Into<String>, text: &'static str) -> (String, Want) { + (cmd.into(), Want::Lacks(text)) +} + +/// Plays the scene in one shell of a new world (bash under `umask 077`) and judges every step. +fn play(kind: ShellKind, pos: Pos, build: impl FnOnce(&World) -> Scene) -> Result<(), String> { + let world = World::new(); + let scene = build(&world); + let cmds: Vec<&str> = scene.iter().map(|(cmd, _)| cmd.as_str()).collect(); + let run = world.shell(kind, if kind == Bash { "umask 077" } else { "" }, &cmds); + run.check()?; + let tip = squash(CD_RECOVERY_TIP); + for (n, ((cmd, want), step)) in scene.iter().zip(&run.steps).enumerate() { + let refused = squash(&step.err); + let moved = n > 0 && step.pwd != run.steps[n - 1].pwd; + let why = match want { + Want::Blocked(text) if !refused.contains(&tip) || !refused.contains(text) => { + "not refused with the tip" + } + Want::Blocked(_) if step.status != 1 => "a refused `cd` must fail with status 1", + Want::Blocked(_) if moved => "a refused `cd` must not move the player", + Want::Blocked(_) => continue, + Want::Runs if matches!(step.status, 126 | 127) => { + "the command was not found or could not run (status 126 or 127)" + } + Want::Ok | Want::At(_) | Want::Done(_) | Want::Says(_) | Want::Lacks(_) + if step.status != 0 => + { + "the step failed" + } + Want::At(dir) if step.pwd != *dir => "the player is in the wrong directory", + Want::Done(done) if step.done(pos) != *done => { + ["done too early", "not done"][*done as usize] + } + Want::Says(text) if !squash(&step.out).contains(&squash(text)) => { + "the output lacks the text" + } + Want::Lacks(text) if squash(&step.out).contains(&squash(text)) => { + "the output has the text" + } + _ if cmd.starts_with("shellgame ") && !step.err.is_empty() => { + "the game wrote to stderr" + } + _ => continue, + }; + return Err(run.report(n, why)); + } + Ok(()) +} + +/// One shell word that bash and fish read alike (the world's paths have spaces, never quotes). +fn quote(path: &Path) -> String { + assert!(!path.to_str().unwrap().contains(['\'', '\\'])); + format!("'{}'", path.display()) +} + +/// Moves the shell without asking the game's `cd` hook, as the `shellgame` function teleports. +fn teleport(kind: ShellKind, dir: &Path) -> String { + format!("{} -- {}", if kind == Bash { "builtin cd" } else { "__shellgame_cd" }, quote(dir)) +} + +fn resume(pos: Pos) -> String { + format!("shellgame resume {pos}") +} + +fn solution(pos: Pos) -> String { + dedent(pos.level().solution) +} + +/// The solution without its final `shellgame submit`: what a player does before submitting. +fn drill(pos: Pos) -> Vec<String> { + let lines = solution(pos).lines().map(str::to_owned).collect::<Vec<_>>(); + lines.into_iter().filter(|line| !line.starts_with("shellgame submit")).collect() +} + +/// `cd /` and one `cd` per directory of `dir`: walking there a step at a time. +fn walk(dir: &Path) -> String { + let steps = + dir.components().skip(1).map(|part| format!("cd {}", quote(Path::new(part.as_os_str())))); + ["cd /".to_owned()].into_iter().chain(steps).collect::<Vec<_>>().join("\n") +} + +fn replay(kind: ShellKind, pos: Pos, solution: &str) -> Result<(), String> { + // `resume` keeps the completion record, so the second round forgets it first: an empty submit must + // not complete the re-prepared level either. + let forget = r#"sed 's/"done": [0-9][0-9]*/"done": null/' "$SHELLGAME_STATE_DIR/state.json" > "$SHELLGAME_STATE_DIR/forget" && mv "$SHELLGAME_STATE_DIR/forget" "$SHELLGAME_STATE_DIR/state.json""#; + // One step per line: the last one submits and completes the level, the others only have to run. + let lines: Vec<&str> = solution.lines().filter(|line| !line.trim().is_empty()).collect(); + let Some((submit, work)) = lines.split_last() else { + return Err("the solution is empty".into()); + }; + let round = || { + let mut scene = vec![done(resume(pos), false), done("shellgame submit", false)]; + scene.extend(work.iter().map(|line| runs(*line))); + scene.push(done(*submit, true)); + scene + }; + play(kind, pos, |_| { + let mut scene = round(); + scene.push(done(forget, false)); + scene.extend(round()); + scene + }) +} + +/// A fresh game, every solution in course order with no resumes: the game advances itself, and +/// intros pass because stdin is not a terminal. In the end the game is finished. +fn playthrough(kind: ShellKind) -> Result<(), String> { + let world = World::new(); + let all: Vec<Pos> = Pos::all().collect(); + let cmds: Vec<String> = + ["shellgame".to_owned()].into_iter().chain(all.iter().map(|&pos| solution(pos))).collect(); + let run = world.shell(kind, "", &cmds); + run.check()?; + for (n, &pos) in all.iter().enumerate() { + let (step, next) = (&run.steps[n + 1], all.get(n + 1)); + let problem = match next { + _ if !step.done(pos) => "the solution did not complete the level", + Some(next) if step.state["level"] != next.to_string() => { + "the game did not move on to the next level" + } + Some(next) if step.pwd != start_dir(*next, &world.ws) => { + "the shell was not moved to the next start" + } + None if step.state["finished"] != true => { + "the last level is done, but the game is not finished" + } + _ => continue, + }; + return Err(run.report(n + 1, &format!("level {pos}: {problem}"))); + } + Ok(()) +} + +/// Levels with a `cd` policy never lock the player in. After `resume`: a move the policy refuses is +/// refused, with the recovery tip; moving to the start directory is always allowed, from the +/// workspace and the section root; once the drill is done the same move is allowed; `reset` makes +/// the level strict again. +fn strict_cd(kind: ShellKind, pos: Pos) -> Result<(), String> { + play(kind, pos, |world| { + let (start, section) = (start_dir(pos, &world.ws), pos.section_dir(&world.ws)); + // The binary's `cd-hook` tells which of these moves the level refuses at its start. + assert_eq!(world.run(&["resume", &pos.to_string()]).code, 0); + let refuses = |target: &str| { + world.run(&["cd-hook", "pre", start.to_str().unwrap(), "", "--", target]).code == 3 + }; + let lines = drill(pos); + let (wrong, lead) = match ["/etc", "..", "-"].into_iter().find(|target| refuses(target)) { + Some(target) => (target, 0), + // A rule that applies only deeper (`when_in`): the drill's first `cd` leads there. + None => ( + "/etc", + lines + .iter() + .position(|line| line.starts_with("cd ")) + .expect("a strict level's drill moves") + + 1, + ), + }; + let (lead, rest) = lines.split_at(lead); + let lead_in = + |scene: &mut Scene| scene.extend((!lead.is_empty()).then(|| ok(lead.join("\n")))); + let (go_wrong, to_start) = (format!("cd {wrong}"), format!("cd {}", quote(&start))); + + let mut scene = vec![at(resume(pos), &start)]; + lead_in(&mut scene); + scene.push(blocked(&go_wrong, "")); + for from in [&world.ws, §ion] { + scene.extend([ok(teleport(kind, from)), at(&to_start, &start)]); + } + lead_in(&mut scene); + scene.push(ok(rest.join("\n"))); + if !lead.is_empty() { + scene.push(ok(teleport(kind, &start))); + lead_in(&mut scene); + } + scene.extend([ok(&go_wrong), at("shellgame reset", &start)]); + lead_in(&mut scene); + scene.push(blocked(&go_wrong, "")); + scene + }) +} + +/// The one level with a `cd` policy whose solution fits `is`. +fn strict_level(what: &str, is: impl Fn(&str) -> bool) -> Result<Pos, String> { + let found: Vec<Pos> = + Pos::all().filter(|&pos| !pos.level().cd.is_empty() && is(&solution(pos))).collect(); + match found[..] { + [pos] => Ok(pos), + _ => Err(format!("expected one strict level for {what}, found {found:?}")), + } +} + +/// What the solution of the absolute-path level starts with: `cd` to the level's own directory. +const ABSOLUTE_CD: &str = r#"cd "$PWD/"#; + +/// `cd /` is absolute but not the lesson, and relative moves from `/` stay refused: only arriving +/// by an absolute `cd` from the start completes the level. +fn absolute_path(world: &World, kind: ShellKind, pos: Pos) -> Scene { + let start = start_dir(pos, &world.ws); + let solution = solution(pos); + let target = + solution.lines().next().unwrap().strip_prefix(ABSOLUTE_CD).unwrap().trim_end_matches('"'); + vec![ + at(resume(pos), &start), + at("cd /", Path::new("/")), + blocked("cd etc", ""), + ok(teleport(kind, &start.join(target))), + done("shellgame submit", false), + ok(teleport(kind, &start)), + done(solution, true), + ] +} + +/// `cd -` back to the start is allowed because it returns to the start, and it still counts; being +/// back by any other way does not. +fn cd_dash(world: &World, kind: ShellKind, pos: Pos) -> Scene { + let start = start_dir(pos, &world.ws); + let away = solution(pos).lines().next().unwrap().to_owned(); + vec![ + at(resume(pos), &start), + ok(&away), + ok(teleport(kind, &start)), + done("shellgame submit", false), + ok(&away), + at("cd -", &start), + done("shellgame submit", true), + ] +} + +/// A bare `cd` would leave the maze and is refused; `reset` inside the maze, which is rebuilt +/// under the player, leaves them in the new entrance, twice. +fn maze(world: &World, _: ShellKind, pos: Pos) -> Scene { + let entrance = start_dir(pos, &world.ws); + let inside = solution(pos).lines().next().unwrap().to_owned(); + vec![ + at(resume(pos), &entrance), + blocked("cd", ""), + ok(inside), + at("shellgame reset", &entrance), + at("shellgame reset", &entrance), + ] +} + +/// Every shortcut home is refused, and a symlink to `$HOME` is a single step but no walk, since the +/// shell's logical directory is not `$HOME`; a real walk completes the level. +fn home_walk(world: &World, _: ShellKind, pos: Pos) -> Scene { + let (start, shortcut) = (start_dir(pos, &world.ws), world.ws.join("shortcut")); + vec![ + at(resume(pos), &start), + blocked("cd ~", ""), + blocked("cd", ""), + blocked("cd a/b", ""), + done("shellgame submit", false), + ok(r#"ln -s "$HOME" "$SHELLGAME_WORKSPACE/shortcut""#), + at(walk(&world.ws), &world.ws), + at("cd shortcut", &shortcut), + done("shellgame submit", false), + done(format!("{}\nshellgame submit", walk(&world.home)), true), + ] +} + +/// The first level that is judged on where the player stands: with no directory, it says so. +fn locating_level() -> Result<Pos, String> { + let world = World::new(); + let lost = |pos: &Pos| { + prepare(*pos, &world.ws).is_ok_and(|()| { + let ctx = Ctx::new(&world.ws, *pos, None, &world.home); + pos.level().judge(&ctx, None) == Err(CWD_MISSING.to_owned()) + }) + }; + Pos::all().find(lost).ok_or_else(|| "no level is judged on the player's directory".into()) +} + +/// The player deletes the directory they stand in (one outside the level, which a strict `cd` rule +/// would not let them enter): the game still runs there and tells them, `reset` brings them back to +/// the start, and the level can be played. +fn deleted_cwd(world: &World, kind: ShellKind, pos: Pos) -> Scene { + let lost = world.tmp.join("lost"); + let delete = format!("mkdir {0}\n{1}\nrmdir {0}", quote(&lost), teleport(kind, &lost)); + vec![ + at(resume(pos), &start_dir(pos, &world.ws)), + says(format!("{delete}\nshellgame submit\nshellgame reset"), CWD_MISSING), + done(solution(pos), true), + ] +} + +/// The player takes the `x` away from the directory above them (`chmod a-x ..`): `$PWD` cannot be +/// resolved any more, but the directory is still there, and the game judges them where they are. +fn locked_parent(world: &World, kind: ShellKind, pos: Pos) -> Scene { + let start = start_dir(pos, &world.ws); + let child = start.join("child"); + vec![ + at(resume(pos), &start), + ok(format!("mkdir {}", quote(&child))), + at(teleport(kind, &child), &child), + ok("chmod a-x .."), + lacks("shellgame submit", CWD_MISSING), + ok("chmod u+x .."), + at("shellgame reset", &start), + ] +} + +/// A trial in `kind` of the level `find` picks; a level it cannot pick fails this trial only. +fn trial( + name: String, + kind: ShellKind, + find: impl FnOnce() -> Result<Pos, String> + Send + 'static, + run: impl FnOnce(Pos) -> Result<(), String> + Send + 'static, +) -> Trial { + Trial::ignorable_test(name, move || { + let pos = find().map_err(Failed::from)?; + judged(kind, &solution(pos), || run(pos)) + }) +} + +/// What `run` says, or ignored with a note when `kind` cannot run `script` here. +fn judged( + kind: ShellKind, + script: &str, + run: impl FnOnce() -> Result<(), String>, +) -> Result<Completion, Failed> { + match skip(kind, script) { + Some(why) => Ok(Completion::ignored_with(why)), + None => run().map(|()| Completion::Completed).map_err(Failed::from), + } +} + +/// The first line of `tool --version`. +fn version(tool: &str) -> String { + let output = Command::new(tool).arg("--version").output(); + let text = + output.map(|o| String::from_utf8_lossy(&o.stdout).lines().next().unwrap_or("").to_owned()); + text.unwrap_or_else(|_| format!("no {tool}")) +} + +/// Why `kind` cannot run `script` here: fish is optional, and only fish 3.4 has `$(...)`. +fn skip(kind: ShellKind, script: &str) -> Option<&'static str> { + static FISH: OnceLock<[bool; 2]> = OnceLock::new(); + let runs = |script| { + let probe = Command::new("fish").args(["--no-config", "-c", script]).output(); + probe.is_ok_and(|output| output.status.success()) + }; + let [installed, substitutes] = *FISH.get_or_init(|| [runs("true"), runs("echo $(echo)")]); + match kind { + Fish if !installed => Some("fish is missing"), + Fish if !substitutes && script.contains("$(") => { + Some("the solution uses `$(...)`, which fish has only from 3.4") + } + _ => None, + } +} + +fn main() { + let args = Arguments::from_args(); + let (bash, fish) = (version("bash"), version("fish")); + eprintln!("e2e: {bash}; {fish}; Landlock {}", ["off", "on"][landlock() as usize]); + + // The long trials first: they run next to all the short ones instead of alone after them. + let mut trials = vec![]; + // Each finds its level when it runs, so a level that changed fails its own trial only. + let regressions: [(&str, Find, Build); 6] = [ + ("absolute-path", || strict_level("absolute", |s| s.contains(ABSOLUTE_CD)), absolute_path), + ("cd-dash", || strict_level("cd -", |s| s.lines().any(|line| line == "cd -")), cd_dash), + ( + "maze", + || strict_level("maze", |s| s.lines().filter(|l| l.starts_with("cd ")).count() > 10), + maze, + ), + // The one that starts its walk at the root. + ("home-walk", || strict_level("home walk", |s| s.starts_with("cd /\n")), home_walk), + ("deleted-cwd", locating_level, deleted_cwd), + ("locked-parent", locating_level, locked_parent), + ]; + let course: String = Pos::all().map(solution).collect(); + for kind in [Bash, Fish] { + let course = course.clone(); + trials.push(Trial::ignorable_test(format!("playthrough::{kind}"), move || { + judged(kind, &course, || playthrough(kind)) + })); + for (name, find, scene) in regressions { + trials.push(trial(format!("regression::{name}::{kind}"), kind, find, move |pos| { + play(kind, pos, |world| scene(world, kind, pos)) + })); + } + } + for pos in Pos::all() { + for kind in [Bash, Fish] { + let name = format!("solutions::{pos}::{kind}"); + trials.push(trial( + name, + kind, + move || Ok(pos), + move |pos| replay(kind, pos, &solution(pos)), + )); + if !pos.level().cd.is_empty() { + let name = format!("strict_cd::{pos}::{kind}"); + trials.push(trial(name, kind, move || Ok(pos), move |pos| strict_cd(kind, pos))); + } + } + } + trials.push(Trial::test("harness::a-wrong-solution-fails", || { + let pos = Pos::all().next().unwrap(); + let unknown = format!("no-such-command-in-the-replay\n{}", solution(pos)); + let wrong = [("does nothing", "true".to_owned()), ("runs an unknown command", unknown)]; + for (what, wrong) in wrong { + if replay(Bash, pos, &wrong).is_ok() { + return Err(format!("a solution that {what} passed").into()); + } + } + Ok(()) + })); + libtest_mimic::run(&args, trials).exit(); +} diff --git a/tests/fakes.py b/tests/fakes.py deleted file mode 100644 index b38190a..0000000 --- a/tests/fakes.py +++ /dev/null @@ -1,147 +0,0 @@ -"""Shared test doubles for :class:`GameSession` tests. - -Kept in one place so that every session-level test exercises the same contract -surface as the real collaborators. -""" - -from __future__ import annotations - -from pathlib import Path -from typing import Any - -from shellgame.levels.registry import UnknownLevelError -from shellgame.state.manager import GameState - - -class _FakeDisplay: - def __init__(self) -> None: - self.calls: list[tuple[str, tuple[Any, ...], dict[str, Any]]] = [] - - def __getattr__(self, name: str) -> Any: - def recorder(*args: Any, **kwargs: Any) -> None: - self.calls.append((name, args, kwargs)) - - return recorder - - def names(self) -> list[str]: - return [name for name, _, _ in self.calls] - - def call(self, name: str) -> tuple[tuple[Any, ...], dict[str, Any]]: - for called, args, kwargs in self.calls: - if called == name: - return args, kwargs - raise AssertionError(f"{name} was never called; got {self.names()}") - - -class _FakeConsole: - def __init__(self) -> None: - self.lines: list[str] = [] - - def print(self, *args: Any, **kwargs: Any) -> None: - self.lines.append(" ".join(str(a) for a in args)) - - -class _FakeShell: - def __init__(self) -> None: - self.exported: dict[str, str] = {} - - def export(self, name: str, value: str) -> None: - self.exported[name] = value - - def cd(self, path: Path) -> None: ... - - def exit(self) -> None: ... - - -class _FakeLevel: - is_intro = False - enforce_start_directory = False - - def __init__( - self, - level_id: str, - *, - result: tuple[bool, str] = (True, "Level-specific success!"), - optional: bool = False, - extension: bool = False, - prepare_error: Exception | None = None, - ) -> None: - self.id = level_id - self.title = f"Level {level_id}" - self.hints: list[str] = [] - self.optional = optional - self.extension = extension - self._result = result - self._prepare_error = prepare_error - self.prepared = 0 - - @property - def is_bonus(self) -> bool: - return self.optional or self.extension - - def validate(self, answer: str | None, state: GameState) -> tuple[bool, str]: - return self._result - - def prepare(self, workspace: Path) -> None: - if self._prepare_error is not None: - raise self._prepare_error - self.prepared += 1 - - def reset(self, workspace: Path) -> None: ... - - def get_start_directory(self, workspace: Path) -> Path: - return workspace - - -class _FakeRegistry: - def __init__(self, levels: list[_FakeLevel], *, nearest: str | None = None) -> None: - self._levels = levels - self._by_id = {level.id: level for level in levels} - self._nearest = nearest - - def get(self, level_id: str) -> _FakeLevel | None: - return self._by_id.get(level_id) - - def list_levels(self) -> list[_FakeLevel]: - return list(self._levels) - - def next_level(self, level_id: str) -> str | None: - ids = [level.id for level in self._levels] - if level_id not in ids: - raise UnknownLevelError(level_id) - index = ids.index(level_id) - return ids[index + 1] if index + 1 < len(ids) else None - - def resolve_or_nearest(self, level_id: str) -> str | None: - if level_id in self._by_id: - return level_id - if self._nearest is not None: - return self._nearest - return self._levels[0].id if self._levels else None - - -class _FakeWorkspace: - def __init__(self, root: Path) -> None: - self.root = root - - def init(self) -> Path: - self.root.mkdir(parents=True, exist_ok=True) - return self.root - - def remove(self) -> None: ... - - -class _FakeStateManager: - def __init__(self, state: GameState | None) -> None: - self.state = state - self.saves = 0 - - def load(self) -> GameState | None: - return self.state - - def save(self, state: GameState) -> None: - self.saves += 1 - self.state = state - - def remove(self) -> None: - self.state = None diff --git a/tests/sandbox.rs b/tests/sandbox.rs new file mode 100644 index 0000000..04f7331 --- /dev/null +++ b/tests/sandbox.rs @@ -0,0 +1,221 @@ +//! The game shell cannot change anything outside /tmp, the game's state directory and /dev, and is +//! not confined further. The real binary and scripts run through `launch_with` in bash and fish, in +//! a fake home full of victims and a state directory outside /tmp. Without Landlock the tests skip +//! with a note, unless `SHELLGAME_TEST_LANDLOCK=required` (CI sets it) turns that into a failure. + +mod support; + +use std::collections::BTreeMap; +use std::fs::{self, File}; +use std::os::unix::fs::PermissionsExt; +use std::os::unix::net::UnixListener; + +use shellgame::shell::{LaunchOptions, ShellKind, launch_with}; +use support::{BIN, World, have, landlock}; + +/// What the player types in the game shell, where `$H` is the fake home, `$S` a directory in /tmp +/// and `$P` the test process, which runs outside the sandbox; each after its kind: +/// +/// * `ok` always works; +/// * an `attack` on the home works only without the sandbox; +/// * an `escape` never works in the sandbox, and without it may fail anyway (a hard link cannot +/// cross filesystems); +/// * a typed `chmod` that would reach the home is `guarded` by the scripts even without it; +/// * `ignored` does not matter. +const STEPS: [&str; 27] = [ + "ok ln -s $H/trapped $S/trap", + "attack touch $H/new-file", + "attack rm $H/rm-me", + "attack mv $H/mv-me $H/moved", + "attack echo x > $H/write-me", + "attack echo x >> $H/append-me", + "attack truncate -s 0 $H/truncate-me", + "attack mkdir $H/new-dir", + "attack rmdir $H/rmdir-me", + "attack ln -s write-me $H/link", + "attack echo x > $S/trap", + "escape ln $H/hard-me $S/hard", + "ignored echo x >> $S/hard", + "guarded chmod 000 $H/chmod-me", + "guarded chmod 000 $S/trap", + "ok touch $S/a", + "ok mkdir $S/d", + "ok echo hi > $S/b", + "ok mv $S/b $S/d/b", + "ok chmod 600 $S/d/b", + "ok rm $S/a", + "ok mktemp > $S/mktemp.out", + "ok echo x > /dev/null", + // Signals to processes outside stay allowed: the sandbox is about files only. + "ok kill -0 $P", + "ignored cp $SHELLGAME_STATE_DIR/state.json $S/before.json", + "ok shellgame submit start", + "ok cp $SHELLGAME_STATE_DIR/state.json $S/after.json", +]; + +/// Steps that need python3: `truncate(2)` itself (the `truncate` tool opens the file for writing +/// first, which Landlock refuses even where it knows nothing of truncation), and a connection to a +/// unix socket outside /tmp, where the test listens, which the sandbox must not forbid. +const PYTHON_STEPS: [&str; 2] = [ + "attack python3 -I -c 'import os, sys; os.truncate(sys.argv[1], 0)' $H/shrink-me", + "ok python3 -I -c 'import socket, sys; socket.socket(socket.AF_UNIX).connect(sys.argv[1])' $H/sock", +]; + +fn shells() -> Vec<ShellKind> { + let found: Vec<_> = + [ShellKind::Bash, ShellKind::Fish].into_iter().filter(|shell| have(shell.name())).collect(); + if found.len() < 2 { + eprintln!("SKIPPED: only {found:?} of bash and fish is installed"); + } + assert!(!found.is_empty(), "bash is needed"); + found +} + +/// A world whose home holds the victims and a listening unix socket (`None` with a note where +/// python3 is missing or the socket cannot be made, and then [`PYTHON_STEPS`] are left out); +/// `w.cwd` is the scratch directory in /tmp. +fn victims() -> (World, Option<UnixListener>) { + let w = World::new(); + fs::create_dir(w.home.join("rmdir-me")).unwrap(); + let names = "rm-me mv-me write-me append-me truncate-me shrink-me hard-me chmod-me trapped"; + for victim in names.split(' ') { + fs::write(w.home.join(victim), "precious\n").unwrap(); + } + let socket = match have("python3") { + true => UnixListener::bind(w.home.join("sock")) + .inspect_err(|error| eprintln!("SKIPPED: no unix socket in the home: {error}")) + .ok(), + false => { + eprintln!("SKIPPED: python3 is needed for the truncate(2) and unix socket steps"); + None + } + }; + (w, socket) +} + +/// Every entry of the home with its mode and content: what an attack would change. +fn home(w: &World) -> BTreeMap<String, String> { + let mut seen = BTreeMap::new(); + for entry in fs::read_dir(&w.home).unwrap() { + let path = entry.unwrap().path(); + let mode = fs::symlink_metadata(&path).unwrap().permissions().mode(); + let content = fs::read_to_string(&path).unwrap_or_default(); + seen.insert(path.display().to_string(), format!("{mode:o} {content}")); + } + seen +} + +fn scratch(w: &World, name: &str) -> String { + fs::read_to_string(w.cwd.join(name)).unwrap_or_default() +} + +/// Plays [`STEPS`] (and [`PYTHON_STEPS`] with `python`) in a game shell that is fed from a file and +/// started the way the player's is, and checks that each worked as its kind says. Returns what the +/// shell wrote to stderr. +fn play(w: &World, shell: ShellKind, sandbox: bool, python: bool) -> String { + let status = ["$?", "$status"][usize::from(shell == ShellKind::Fish)]; + let extra = if python { &PYTHON_STEPS[..] } else { &[] }; + let steps: Vec<_> = + STEPS.iter().chain(extra).map(|step| step.split_once(' ').unwrap()).collect(); + let script: String = + steps.iter().map(|(_, command)| format!("{command}\necho \"@{status}\"\n")).collect(); + let [input, out, err] = ["input", "stdout", "stderr"].map(|name| w.root.join(name)); + fs::write(&input, script).unwrap(); + let options = LaunchOptions { shell, binary: BIN.into(), state_dir: w.state.clone(), sandbox }; + // A fish player has fish's own directories (see `World::new`); fish 3.3 warns when it cannot + // make them, and the sandbox forbids that. + for dir in ["CONFIG/fish", "DATA/fish"] { + fs::create_dir_all(w.out.join(dir)).unwrap(); + } + + let status = launch_with(&options, |command| { + command.stdin(File::open(&input).unwrap()).current_dir(&w.cwd); + command.stdout(File::create(&out).unwrap()).stderr(File::create(&err).unwrap()); + command.env("S", &w.cwd).env("H", &w.home).env("HOME", &w.home); + command.env("P", std::process::id().to_string()); + command.env("SHELLGAME_STATE_DIR", &w.state).env("SHELLGAME_WORKSPACE", &w.ws); + command.env_remove("HISTFILE"); + // Nothing of the developer's own fish or XDG directories is touched without the sandbox. + for xdg in ["CONFIG", "DATA", "STATE", "CACHE"] { + command.env(format!("XDG_{xdg}_HOME"), w.out.join(xdg)); + } + }); + + // `mktemp` made its file in /tmp (the launcher sets TMPDIR): remove it before anything can fail. + let temp = scratch(w, "mktemp.out"); + let removed = temp.starts_with("/tmp/") && fs::remove_file(temp.trim()).is_ok(); + assert!(status.unwrap().success(), "{shell}: the shell failed"); + let stdout = fs::read_to_string(out).unwrap(); + let results: Vec<_> = stdout.lines().filter_map(|line| line.strip_prefix('@')).collect(); + assert_eq!(results.len(), steps.len(), "{shell} stopped early"); + for ((kind, command), ok) in steps.into_iter().zip(results.iter().map(|code| *code == "0")) { + let wanted = match kind { + "attack" => Some(!sandbox), + "escape" => sandbox.then_some(false), + "guarded" => Some(false), + "ignored" => None, + _ => Some(true), + }; + assert!(wanted.is_none_or(|wanted| wanted == ok), "{shell} sandbox={sandbox}: {command}"); + } + assert!(removed, "{shell}: mktemp printed {temp:?}"); + fs::read_to_string(err).unwrap() +} + +#[test] +fn the_game_shell_cannot_change_files_outside_tmp() { + if !landlock() { + return eprintln!("SKIPPED: Landlock is not available here"); + } + for shell in shells() { + let (w, socket) = victims(); + let before = home(&w); + + let stderr = play(&w, shell, true, socket.is_some()); + + // The home is untouched, the files in /tmp are fine, and `shellgame submit` could write its + // state in the state directory. + assert_eq!(home(&w), before, "{shell}: the home changed"); + let (state_before, state_after) = (scratch(&w, "before.json"), scratch(&w, "after.json")); + assert!(!state_after.is_empty() && state_after != state_before, "{shell}: state.json"); + let noise = stderr.to_lowercase(); + assert!(!noise.contains("history") && !noise.contains("fish_variables")); + } +} + +#[test] +fn without_the_sandbox_the_same_steps_do_change_the_home() { + if !landlock() { + return eprintln!("SKIPPED: Landlock is not available here"); + } + for shell in shells() { + let (w, socket) = victims(); + let before = home(&w); + + play(&w, shell, false, socket.is_some()); + + let path = |name: &str| w.home.join(name); + let read = |name: &str| fs::read_to_string(path(name)).unwrap(); + assert_ne!(home(&w), before); + assert!(path("new-file").exists() && path("moved").exists() && !path("rm-me").exists()); + assert_eq!([read("write-me"), read("truncate-me")], ["x\n", ""]); + if socket.is_some() { + assert_eq!(read("shrink-me"), ""); + } + // The link in /tmp led to a victim. + assert_eq!(read("trapped"), "x\n"); + } +} + +#[test] +fn the_guard_command_refuses_with_status_3() { + let w = World::new(); + let guard = |path: &str| w.run(&["guard", "--", "755", path]); + + let refused = guard("/etc"); + + // It refuses with an explanation that names the path. + assert_eq!(refused.code, 3); + assert!(refused.err.contains("/etc"), "{refused:?}"); + assert_eq!([guard("/tmp").code, guard("/tmp/no-such-file-for-the-guard").code], [3, 0]); +} diff --git a/tests/shell.rs b/tests/shell.rs new file mode 100644 index 0000000..e079731 --- /dev/null +++ b/tests/shell.rs @@ -0,0 +1,401 @@ +//! The shell integration in real shells with a mock game binary (support/mock_shellgame.sh). Each +//! shell test exists as `<name>::bash` and `<name>::fish`; fish and shellcheck are skipped with a note +//! when they are not installed. + +mod support; + +use std::fs::{self, File}; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use shellgame::game::dedent; +use shellgame::shell::protocol::encode; +use shellgame::shell::{LaunchOptions, ShellKind, launch_with}; +use support::{Run, World, have}; + +macro_rules! shell_test { + ($name:ident, |$sh:ident| $body:block) => { + mod $name { + use super::*; + + #[test] + fn bash() { + let $sh = ShellKind::Bash; + $body + } + + #[test] + fn fish() { + let $sh = ShellKind::Fish; + if !have("fish") { + return eprintln!("SKIPPED: fish is not installed"); + } + $body + } + } + }; +} + +fn lines(lines: &[&str]) -> String { + lines.join("\n") + "\n" +} + +fn line(path: &Path) -> String { + format!("{}\n", path.display()) +} + +/// A world whose game binary is the mock, which logs every call to `mock.log`. +fn mock() -> World { + let mut w = World::new(); + w.bin = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/support/mock_shellgame.sh"); + w.set("MOCK_LOG", w.root.join("mock.log")); + w +} + +fn mkdir(w: &World, relative: &str) -> PathBuf { + let path = w.root.join(relative); + fs::create_dir_all(&path).unwrap(); + path +} + +/// Sources the integration script, then runs `body` (a command, or an indented block of them) in +/// the shell, where `$STATUS` is `$?` or `$status`: what it printed, and the status the shell +/// exited with. +fn play(w: &World, shell: ShellKind, body: &str) -> Run { + let status = if shell == ShellKind::Bash { "$?" } else { "$status" }; + let run = w.shell(shell, "", &[dedent(body).replace("$STATUS", status)]); + Run { code: run.code, out: run.steps[0].out.clone(), err: run.steps[0].err.clone() } +} + +/// The values the body printed as `name:<value>` lines (fish rings a bell for `cd -` at the start). +fn tagged<'a>(run: &'a Run, name: &str) -> Vec<&'a str> { + let lines = run.out.lines().map(|line| line.trim_start_matches('\u{7}')); + lines.filter_map(|line| line.strip_prefix(&format!("{name}:"))).collect() +} + +/// The argv of a `cd-hook` call: `a` and `b` are the directories before and after (`pre`: the +/// shell's directory and where `cd -` would go, or empty). +fn hook(phase: &str, a: impl AsRef<Path>, b: impl AsRef<Path>, args: &[&str]) -> Vec<String> { + let (a, b) = (a.as_ref().to_string_lossy(), b.as_ref().to_string_lossy()); + ["cd-hook", phase, &a, &b, "--"].iter().chain(args).map(|word| word.to_string()).collect() +} + +/// The argv of the mock's calls whose kind (`game`, `cd-hook`, `guard`) starts with +/// `kind`; an empty `kind` means all of them (the kind is not part of the argv). +fn argvs(w: &World, kind: &str) -> Vec<Vec<String>> { + let log = fs::read_to_string(w.root.join("mock.log")).unwrap_or_default(); + let calls = log.lines().filter(|line| line.starts_with(kind)); + calls.map(|call| call.split('\u{1f}').skip(1).map(str::to_owned).collect()).collect() +} + +fn tmp_is_empty(w: &World) -> bool { + fs::read_dir(&w.tmp).unwrap().next().is_none() +} + +shell_test!(replay_runs_directives_and_passes_stderr, |sh| { + let mut h = mock(); + let dir = mkdir(&h, "dir with spaces/and; $(touch PWNED) `touch PWNED` 'q'"); + let foo = "bar baz; touch PWNED $(touch PWNED) `touch PWNED`"; + let plain = "back\\slash $HOME `id` * Příliš žluťoučký"; + let set = |name: &str| encode("export", &[name, "1"]); + let wire = lines(&[ + // Written by hand, since this format is a contract: HELLO=World. + "__SHELLGAME_EXEC__v1 export SEVMTE8= V29ybGQ=", + &encode("export", &["FOO", foo]), + &encode("export", &["EMPTY", ""]), + &encode("cd", &[dir.to_str().unwrap()]), + // Dropped silently: unknown version and verb, wrong argument counts, invalid names. + "__SHELLGAME_EXEC__v2 export V1JPTkc= MQ==", + "__SHELLGAME_EXEC__v1 eval dG91Y2ggUFdORUQ=", + "__SHELLGAME_EXEC__v1 cd", + "__SHELLGAME_EXEC__v1 exit SGk=", + &encode("export", &["X; touch PWNED", "x"]), + ]); + // A marker inside a line is text, one CR is stripped, the rest is replayed as it is. + let rest = format!( + " indented \n-n\n{plain}\n\nbefore {}\n{}\r\nplain\r\nend", + set("INLINE"), + set("RAN") + ); + h.set("MOCK_STDOUT", "game output\n").set("MOCK_STDERR", wire + &rest); + let body = r#" + shellgame + echo "vars:$HELLO $FOO [$EMPTY] [$WRONG$INLINE] [$RAN]" + echo "here:$PWD" + "#; + + let run = play(&h, sh, body); + + let out = format!("game output\nvars:World {foo} [] [] [1]\nhere:{}\n", dir.display()); + assert_eq!(run.out, out); + let err = format!(" indented \n-n\n{plain}\n\nbefore {}\nplain\nend\n", set("INLINE")); + assert_eq!(run.err, err); + let clean = [&h.cwd, &dir, &h.home].iter().all(|place| !place.join("PWNED").exists()); + assert!(clean, "nothing but the directives ran"); +}); + +shell_test!(exit_directive_closes_the_shell, |sh| { + let mut h = mock(); + h.set("MOCK_STDERR", lines(&[&encode("exit", &[] as &[&str])])); + let body = r#" + shellgame + echo SHOULD_NOT_RUN + "#; + + let run = play(&h, sh, body); + + assert_eq!((run.code, run.out.as_str()), (0, "")); + assert!(tmp_is_empty(&h), "no temporary file is left"); +}); + +shell_test!(arguments_arrive_verbatim_and_status_too, |sh| { + let args: Vec<_> = + "; touch PWNED|$(touch PWNED)|`touch PWNED`|a b||--|-x|*".split('|').collect(); + let quoted: Vec<_> = args.iter().map(|arg| format!("'{arg}'")).collect(); + let body = format!( + r#" + shellgame {} + echo "exit:$STATUS" + "#, + quoted.join(" ") + ); + let mut h = mock(); + h.set("MOCK_EXIT", "3"); + + let run = play(&h, sh, &body); + + assert_eq!(tagged(&run, "exit"), ["3"]); + assert_eq!(argvs(&h, "game"), std::slice::from_ref(&args)); + assert!(!h.cwd.join("PWNED").exists() && tmp_is_empty(&h)); +}); + +shell_test!(pwd_leaves_evidence_in_a_real_workspace, |sh| { + let body = r#" + pwd + echo "exit:$STATUS" + "#; + for workspace in ["ws", "missing", "", "unset"] { + let mut h = mock(); + let ws = mkdir(&h, "ws"); + match workspace { + "unset" => h.unset("SHELLGAME_WORKSPACE"), + "" => h.set("SHELLGAME_WORKSPACE", ""), + _ => h.set("SHELLGAME_WORKSPACE", h.root.join(workspace)), + }; + + let run = play(&h, sh, body); + + assert_eq!(run.out, format!("{}exit:0\n", line(&h.cwd))); + let stray = [&h.cwd, &h.home, &h.root].iter().any(|dir| dir.join(".pwd_used").exists()); + assert_eq!(ws.join(".pwd_used").exists(), workspace == "ws", "{workspace:?}"); + assert!(!stray, "the workspace is never guessed: {workspace:?}"); + } +}); + +shell_test!(cd_hook_receives_every_argument, |sh| { + let h = mock(); + let (pre, a) = (mkdir(&h, "cwd/pre"), mkdir(&h, "cwd/a")); + let spaced = mkdir(&h, "cwd/dir with spaces"); + let body = r#" + cd pre + cd -- ../a + cd '../dir with spaces' + cd + cd nonexistent + cd - + cd - + "#; + + play(&h, sh, body); + + let (work, home) = (&h.cwd, &h.home); + // `<prev>` is empty before the first move and the directory just left after it. A directory + // named `pre` is only an argument and the arguments follow verbatim. + assert_eq!( + argvs(&h, "cd-hook"), + [ + hook("pre", work, "", &["pre"]), + hook("post", work, &pre, &["pre"]), + hook("pre", &pre, work, &["--", "../a"]), + hook("post", &pre, &a, &["--", "../a"]), + hook("pre", &a, &pre, &["../dir with spaces"]), + hook("post", &a, &spaced, &["../dir with spaces"]), + hook("pre", &spaced, &a, &[]), + hook("post", &spaced, home, &[]), + // A move that fails is not reported. `cd -` goes where `<prev>` said, and back again. + hook("pre", home, &spaced, &["nonexistent"]), + hook("pre", home, &spaced, &["-"]), + hook("post", home, &spaced, &["-"]), + hook("pre", &spaced, home, &["-"]), + hook("post", &spaced, home, &["-"]), + ] + ); +}); + +shell_test!(cd_hook_blocks_on_3_and_fails_open, |sh| { + let mut h = mock(); + let target = mkdir(&h, "target"); + mkdir(&h, "cwd/a"); + h.set("MOCK_PRE", "3").set("MOCK_NOTE", "BLOCKED: stay"); + h.set("MOCK_STDERR", encode("cd", &[target.to_str().unwrap()]) + "\n"); + let body = r#" + cd a + echo "exit:$STATUS" + pwd + shellgame + cd .. + pwd + "#; + + let run = play(&h, sh, body); + + assert_eq!(run.out, format!("exit:1\n{}{}", line(&h.cwd), line(&target))); + assert!(run.err.contains("BLOCKED: stay"), "the player sees the explanation"); + // A blocked move is not reported; a teleport never asks the hook: the game moves with its own cd. + let asked = [hook("pre", &h.cwd, "", &["a"]), hook("pre", &target, &h.cwd, &[".."])]; + assert_eq!(argvs(&h, "cd-hook"), asked); + + // Any other status, a missing binary and an empty one: the move happens. + let body = r#" + cd a + echo "exit:$STATUS" + pwd + "#; + for (name, value) in + [("MOCK_PRE", "1"), ("MOCK_PRE", "255"), ("SHELLGAME_BIN", "/x"), ("SHELLGAME_BIN", "")] + { + let mut h = mock(); + let a = mkdir(&h, "cwd/a"); + h.set(name, value); + + let run = play(&h, sh, body); + + assert_eq!(run.out, format!("exit:0\n{}", line(&a)), "{name}={value}"); + } +}); + +shell_test!(teleport_keeps_cd_dash_working, |sh| { + let mut h = mock(); + let target = mkdir(&h, "target"); + h.set("MOCK_STDERR", encode("cd", &[target.to_str().unwrap()]) + "\n"); + let body = r#" + shellgame + cd - + echo "here:$PWD" + "#; + + let run = play(&h, sh, body); + + assert_eq!(tagged(&run, "here"), [h.cwd.to_str().unwrap()]); +}); + +shell_test!(chmod_runs_only_when_the_guard_allows, |sh| { + let body = r#" + chmod 600 'f g' + echo "exit:$STATUS" + "#; + for guard in ["3", "0", "broken"] { + let mut h = mock(); + let file = h.cwd.join("f g"); + fs::write(&file, "").unwrap(); + fs::set_permissions(&file, fs::Permissions::from_mode(0o644)).unwrap(); + match guard { + "broken" => h.set("SHELLGAME_BIN", "/x"), + status => h.set("MOCK_GUARD", status), + }; + + let run = play(&h, sh, body); + + let mode = fs::metadata(&file).unwrap().permissions().mode() & 0o777; + let refused = guard == "3"; + assert_eq!( + (tagged(&run, "exit")[0], mode), + if refused { ("1", 0o644) } else { ("0", 0o600) } + ); + if guard != "broken" { + assert_eq!(argvs(&h, "guard"), [["guard", "--", "600", "f g"]]); + } + } +}); + +#[test] +fn bash_functions_survive_set_e_and_set_u() { + // Players paste `set -e` from tutorials: the functions must still move, replay and clean up. + let mut h = mock(); + let a = mkdir(&h, "cwd/a"); + fs::write(h.cwd.join("f"), "").unwrap(); + h.set("MOCK_PRE", "1").set("MOCK_EXIT", "1"); + let replayed = encode("export", &["REPLAYED", "1"]); + h.set("MOCK_STDERR", lines(&[&replayed, "__SHELLGAME_EXEC__v1 export", "plain"])); + + // The shell ends at `shellgame`, which fails: the trap shows what the function did before. + let body = r#" + trap 'echo "replayed:${REPLAYED-}"' EXIT + set -eu + cd a + pwd + cd - + chmod 600 f + shellgame + echo SHOULD_NOT_RUN + "#; + let run = play(&h, ShellKind::Bash, body); + + assert_eq!(run.out, format!("{}{}replayed:1\n", line(&a), line(&h.cwd))); + assert_eq!((run.err.as_str(), run.code), ("plain\n", 1)); + assert!(tmp_is_empty(&h)); +} + +shell_test!(completions_offer_the_commands, |sh| { + let bash = r#" + COMP_WORDS=(shellgame '') + COMP_CWORD=1 + _shellgame_completions + printf '%s\n' "${COMPREPLY[@]}" + "#; + let body = match sh { + ShellKind::Bash => bash, + ShellKind::Fish => "complete -C 'shellgame '", + }; + + let run = play(&mock(), sh, body); + + assert!(run.out.lines().any(|candidate| candidate.starts_with("resume")), "{run:?}"); +}); + +#[test] +fn the_bash_script_is_shellcheck_clean() { + if !have("shellcheck") { + return eprintln!("SKIPPED: shellcheck is not installed"); + } + let script = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/shell/bash.sh"); + let output = Command::new("shellcheck").arg(script).output().unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stdout)); +} + +shell_test!(launch_starts_the_game_shell, |sh| { + let h = mock(); + let (state, input) = (h.root.join("state dir"), h.root.join("input")); + fs::write(&input, "shellgame smoke-arg\n").unwrap(); + let options = + LaunchOptions { shell: sh, binary: h.bin.clone(), state_dir: state, sandbox: false }; + let (out, err, log) = (h.root.join("stdout"), h.root.join("stderr"), h.root.join("mock.log")); + + let status = launch_with(&options, |command| { + command.stdin(File::open(&input).unwrap()).current_dir(&h.cwd); + command.stdout(File::create(&out).unwrap()).stderr(File::create(&err).unwrap()); + command.env("HOME", &h.home).env("MOCK_LOG", &log); + // An interactive bash writes its history and fish makes its directories: in the test's. + command.env_remove("HISTFILE"); + for xdg in ["CONFIG", "DATA", "STATE", "CACHE"] { + command.env(format!("XDG_{xdg}_HOME"), h.out.join(xdg)); + } + }) + .unwrap(); + + let stderr = fs::read_to_string(err).unwrap(); + assert!(status.success(), "{status:?}\n{stderr}"); + // Autostart ran the game; then the piped command ran it again. + assert_eq!(argvs(&h, ""), [vec![], vec!["smoke-arg"]], "{stderr}"); +}); diff --git a/tests/support/mock_shellgame.sh b/tests/support/mock_shellgame.sh new file mode 100755 index 0000000..8fd35ed --- /dev/null +++ b/tests/support/mock_shellgame.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Mock game binary for tests/shell.rs. Every call is logged to $MOCK_LOG as one line of fields joined +# by the unit separator: its kind (`cd-hook`, `guard` or `game`), then the arguments. The MOCK_* +# variables decide what it does: MOCK_EXIT (the status of a game call), MOCK_STDOUT and MOCK_STDERR +# (what it prints), MOCK_PRE and MOCK_POST (the statuses of the `cd` hook; MOCK_NOTE is what `pre` +# prints) and MOCK_GUARD. +US=$'\x1f' +case "${1-}" in cd-hook | guard) kind=$1 ;; *) kind=game ;; esac +entry=$kind +for arg in "$@"; do entry="$entry$US$arg"; done +printf '%s\n' "$entry" >>"$MOCK_LOG" +case "$kind" in + cd-hook) + [ "${2-}" = pre ] || exit "${MOCK_POST-0}" + [ -z "${MOCK_NOTE-}" ] || echo "$MOCK_NOTE" >&2 + exit "${MOCK_PRE-0}" + ;; + guard) exit "${MOCK_GUARD-0}" ;; +esac +printf '%s' "${MOCK_STDOUT-}" +printf '%s' "${MOCK_STDERR-}" >&2 +exit "${MOCK_EXIT-0}" diff --git a/tests/support/mod.rs b/tests/support/mod.rs new file mode 100644 index 0000000..c8afc40 --- /dev/null +++ b/tests/support/mod.rs @@ -0,0 +1,295 @@ +//! Shared by the integration suites: an isolated world per test, a runner for the binary and a +//! single-process runner for the game shell. Nothing here touches the test process's own +//! environment or working directory, so worlds can live on any number of threads. +#![allow(dead_code)] // each suite uses a different subset + +use std::ffi::{OsStr, OsString}; +use std::fs::{self, File}; +use std::io::ErrorKind::ExecutableFileBusy; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::OnceLock; +use std::time::{Duration, Instant}; + +use serde_json::Value; +use shellgame::game::paths::inside_tmp; +use shellgame::game::remove_tree; +use shellgame::sandbox; +use shellgame::shell::{self, ShellKind}; +use tempfile::TempDir; + +pub const BIN: &str = env!("CARGO_BIN_EXE_shellgame"); +const TICK: Duration = Duration::from_millis(2); + +/// The words of a text: escape sequences and box drawing removed, whitespace collapsed. +pub fn squash(text: &str) -> String { + let (mut escape, boxes) = (false, "│╭╮╰╯─"); + let kept = text.chars().filter(|&c| { + let skip = escape || c == '\x1b'; + escape = c == '\x1b' || (escape && !c.is_ascii_alphabetic()); + !skip + }); + let words: String = kept.map(|c| [c, ' '][boxes.contains(c) as usize]).collect(); + words.split_whitespace().collect::<Vec<_>>().join(" ") +} + +pub fn have(tool: &str) -> bool { + Command::new(tool).arg("--version").output().is_ok_and(|output| output.status.success()) +} + +/// Whether Landlock confines here. `SHELLGAME_TEST_LANDLOCK=required` (CI) makes "no" a failure. +pub fn landlock() -> bool { + static WORKS: OnceLock<bool> = OnceLock::new(); + let works = *WORKS.get_or_init(|| sandbox::probe(Path::new(BIN), Path::new("/tmp"))); + let required = std::env::var("SHELLGAME_TEST_LANDLOCK").as_deref() == Ok("required"); + assert!(works || !required, "Landlock is required but unavailable"); + works +} + +/// `$SHELLGAME_TEST_LOCALE` (students play in `cs_CZ.UTF-8`), else `C.UTF-8` where the system has +/// it (level texts are Czech), else `C`. +fn locale() -> &'static str { + static LOCALE: OnceLock<&str> = OnceLock::new(); + LOCALE.get_or_init(|| { + if let Ok(locale) = std::env::var("SHELLGAME_TEST_LOCALE") { + return locale.leak(); + } + let charmap = Command::new("locale").arg("charmap").env("LC_ALL", "C.UTF-8").output(); + let utf8 = charmap.is_ok_and(|o| String::from_utf8_lossy(&o.stdout).trim() == "UTF-8"); + if utf8 { "C.UTF-8" } else { "C" } + }) +} + +#[derive(Debug)] +pub struct Run { + pub code: i32, + pub out: String, + pub err: String, +} + +/// One isolated world per test. `home` and `state` live outside /tmp (below `CARGO_TARGET_TMPDIR`) +/// as they do for a player; the workspace (its name has a space), the working directory and +/// `TMPDIR` live in /tmp. `tools` is for what a test writes and runs (/tmp may be `noexec`); `bin` +/// is the game binary the shells use. +pub struct World { + pub out: PathBuf, + pub home: PathBuf, + pub state: PathBuf, + pub tools: PathBuf, + pub root: PathBuf, + pub ws: PathBuf, + pub cwd: PathBuf, + pub tmp: PathBuf, + pub bin: PathBuf, + env: Vec<(&'static str, Option<OsString>)>, + _dirs: [TempDir; 2], +} + +impl World { + pub fn new() -> World { + let temp = |dir: &str| tempfile::Builder::new().prefix("sg-").tempdir_in(dir).unwrap(); + let (outside, inside) = (temp(env!("CARGO_TARGET_TMPDIR")), temp("/tmp")); + // Canonical, so `$PWD` and `pwd -P` agree where /tmp is a link. + let [out, root] = [&outside, &inside].map(|dir| dir.path().canonicalize().unwrap()); + // The sandbox tests need a home it protects, and the shells write there. + assert!( + !inside_tmp(&out), + "the target directory {} is inside /tmp, where the game shell may write everything: \ + set CARGO_TARGET_DIR to a directory outside /tmp", + out.display() + ); + let [home, state, tools] = ["home", "state", "tools"].map(|name| out.join(name)); + let [ws, cwd, tmp] = ["work space", "cwd", "tmp"].map(|name| root.join(name)); + [&home, &tools, &cwd, &tmp].iter().for_each(|dir| fs::create_dir(dir).unwrap()); + // A fish player's home has fish's own directories (fish makes them on its first start). + // fish 3.3 warns on every start when it cannot create them, and the sandbox forbids that. + for dir in [".config/fish", ".local/share/fish"] { + fs::create_dir_all(home.join(dir)).unwrap(); + } + let (bin, env, _dirs) = (BIN.into(), vec![], [outside, inside]); + World { out, home, state, tools, root, ws, cwd, tmp, bin, env, _dirs } + } + + /// Sets a variable of everything that runs here, or removes it. + pub fn set(&mut self, name: &'static str, value: impl Into<OsString>) -> &mut World { + self.env.push((name, Some(value.into()))); + self + } + + pub fn unset(&mut self, name: &'static str) -> &mut World { + self.env.push((name, None)); + self + } + + /// The world's variables on `cmd`, without clearing anything. `SHELLGAME_SANDBOX` makes the + /// binary act as inside the game shell, with its directives on stderr: `unset` it to run outside. + pub fn apply_env(&self, cmd: &mut Command) { + cmd.env("USER", "ada").env("LC_ALL", locale()).env("HOME", &self.home); + cmd.env("TMPDIR", &self.tmp).env(shell::ENV_SANDBOX, "off"); + cmd.env("SHELLGAME_STATE_DIR", &self.state).env("SHELLGAME_WORKSPACE", &self.ws); + for (name, value) in &self.env { + match value { + Some(value) => cmd.env(name, value), + None => cmd.env_remove(name), + }; + } + } + + /// A command in the cwd with an empty environment but `PATH` and the world's variables. + pub fn command(&self, program: impl AsRef<OsStr>) -> Command { + let mut cmd = Command::new(program); + cmd.env_clear().env("PATH", std::env::var_os("PATH").unwrap_or_default()); + self.apply_env(cmd.current_dir(&self.cwd)); + cmd + } + + /// Runs `cmd` to its end with `stdin` as its input. Output goes to files, so a runaway child + /// cannot block the harness; a hung one is killed after a minute and fails the test. + pub fn exec(&self, cmd: &mut Command, stdin: &str) -> Run { + let [input, out, err] = ["in", "out", "err"].map(|name| self.root.join(format!(".{name}"))); + fs::write(&input, stdin).unwrap(); + cmd.stdin(File::open(input).unwrap()); + cmd.stdout(File::create(&out).unwrap()).stderr(File::create(&err).unwrap()); + let mut child = loop { + match cmd.spawn() { + // A file written a moment ago is "busy" while a thread of another test forks. + Err(e) if e.kind() == ExecutableFileBusy => std::thread::sleep(TICK * 10), + spawned => break spawned.unwrap(), + } + }; + let start = Instant::now(); + let status = loop { + match child.try_wait().unwrap() { + Some(status) => break status, + None if start.elapsed() > Duration::from_secs(60) => { + child.kill().unwrap(); + panic!("{cmd:?} was killed after a minute"); + } + None => std::thread::sleep(TICK), + } + }; + let text = |path: PathBuf| String::from_utf8_lossy(&fs::read(path).unwrap()).into_owned(); + Run { code: status.code().unwrap_or(-1), out: text(out), err: text(err) } + } + + pub fn run(&self, args: &[&str]) -> Run { + self.exec(self.command(BIN).args(args), "") + } + + /// Runs `steps` in ONE game shell process: the real script, hooks and `self.bin`, confined to /tmp, + /// the state directory and /dev when Landlock works (so no level may need more). Each step runs + /// in the shell itself with stdout and stderr in files of its own; afterwards the shell records its + /// status, `$PWD`, whether that directory still exists and the saved game. `prelude` runs first. + pub fn shell(&self, kind: ShellKind, prelude: &str, steps: &[impl AsRef<str>]) -> ShellRun { + let (dir, init) = (self.root.join("steps"), self.out.join(format!("init.{kind}"))); + drop(fs::remove_dir_all(&dir)); // a world may run more than one shell + fs::create_dir_all(&dir).unwrap(); + fs::write(&init, kind.script()).unwrap(); + let (flags, template): (&[&str], _) = match kind { + ShellKind::Bash => (&["--noprofile", "--norc", "-c"], BASH_STEP), + ShellKind::Fish => (&["--no-config", "-c"], FISH_STEP), + }; + let step = |(n, step): (usize, &_)| { + template.replace("{n}", &n.to_string()).replace("{step}", AsRef::<str>::as_ref(step)) + }; + let body: String = steps.iter().enumerate().map(step).collect(); + let script = SCRIPT.replace("{prelude}", prelude).replace("{body}", &body); + // Landlock silently skips a directory that does not exist, and the game saves there. + fs::create_dir_all(&self.state).unwrap(); + let mut cmd = self.command(kind.name()); + cmd.args(flags).arg(script).env("SG", &dir).env(shell::ENV_BIN, &self.bin); + cmd.env(shell::ENV_INIT, init).env(shell::ENV_AUTO_STARTED, "1"); + self.apply_env(&mut cmd); // after the rest, so that a test may override any variable + let confined = landlock(); + cmd.env(shell::ENV_SANDBOX, if confined { "landlock" } else { "off" }); + if confined { + sandbox::confine(&mut cmd, &self.state).unwrap(); + } + let run = self.exec(&mut cmd, ""); + let read = |n: usize, ext: &str| { + fs::read_to_string(dir.join(format!("{n}.{ext}"))).unwrap_or_default() + }; + let steps = steps.iter().enumerate().map(|(n, cmd)| { + let info = read(n, "info"); + let mut words = info.splitn(3, ' '); + let (status, alive) = (words.next().and_then(|w| w.parse().ok()), words.next()); + Step { + cmd: cmd.as_ref().to_owned(), + out: read(n, "out"), + err: read(n, "err"), + status: status.unwrap_or(-1), + alive: alive == Some("1"), + pwd: words.next().unwrap_or_default().into(), + state: serde_json::from_str(&read(n, "state")).unwrap_or_default(), + } + }); + ShellRun { + steps: steps.collect(), + code: run.code, + finished: dir.join("done").exists(), + noise: run.out + &run.err, + } + } +} + +/// A shell run: the prelude, the integration script, the steps and a mark that the last one ran. +const SCRIPT: &str = r#"{prelude} +source "$SHELLGAME_INIT" +{body}printf done >"$SG/done" +"#; + +/// A step, then its status, whether the directory the shell is in still exists, `$PWD` and the saved +/// game. bash redirects with `exec`, not a group (a group parses an `alias` and its use together); +/// fish has no aliases to expand and cannot redirect itself, so it uses a group. The directory +/// exists when `$PWD` names it or, where `$PWD` cannot be resolved (`chmod a-x ..`), when it still +/// has a link: a deleted directory has none. +const BASH_STEP: &str = r#"exec 3>&1 4>&2 >"$SG/{n}.out" 2>"$SG/{n}.err" +{step} +__rc=$? +exec >&3 2>&4 3>&- 4>&- +__gone="$(command find . -maxdepth 0 -links 0 2>/dev/null)" +command test . -ef "$PWD" || [ -z "$__gone" ] && __ok=1 || __ok=0 +printf '%s %s %s' "$__rc" "$__ok" "$PWD" >"$SG/{n}.info" +command cp -- "$SHELLGAME_STATE_DIR/state.json" "$SG/{n}.state" 2>/dev/null || : +"#; +const FISH_STEP: &str = r#"begin +{step} +end >"$SG/{n}.out" 2>"$SG/{n}.err" +set __rc $status +set __gone (command find . -maxdepth 0 -links 0 2>/dev/null) +command test . -ef "$PWD"; or test -z "$__gone"; and set __ok 1; or set __ok 0 +printf '%s %s %s' $__rc $__ok "$PWD" >"$SG/{n}.info" +command cp -- "$SHELLGAME_STATE_DIR/state.json" "$SG/{n}.state" 2>/dev/null +"#; + +impl Drop for World { + /// Levels take permissions away: the game's own `remove_tree` makes everything removable. + fn drop(&mut self) { + [&self.root, &self.out].iter().for_each(|dir| drop(remove_tree(dir))); + } +} + +/// What one step of a shell run did. +#[derive(Debug)] +pub struct Step { + pub cmd: String, + pub out: String, + pub err: String, + /// `$?` after the step; negative when the shell never got through it. + pub status: i32, + /// The shell's logical `$PWD`, whether that directory still exists, and the saved game after + /// the step (`Null` before there is one). + pub pwd: PathBuf, + pub alive: bool, + pub state: Value, +} + +/// The shell's exit status, whether the last line of the script ran, and what the shell printed +/// outside the steps. +#[derive(Debug)] +pub struct ShellRun { + pub steps: Vec<Step>, + pub code: i32, + pub finished: bool, + pub noise: String, +} diff --git a/tests/test_authoring_docs.py b/tests/test_authoring_docs.py deleted file mode 100644 index f2451cf..0000000 --- a/tests/test_authoring_docs.py +++ /dev/null @@ -1,109 +0,0 @@ -"""Keep the authoring guide honest. - -A guide that drifts is worse than none: an author follows it, gets a -`TypeError`, and stops trusting the docs. Every Python example in -`docs/AUTHORING.md` is compiled and executed against the real API here, so a -renamed rule or a changed signature fails the build instead of the reader. -""" - -from __future__ import annotations - -import re -import textwrap -from pathlib import Path -from typing import Any - -import pytest - -from shellgame.cli.commands import cli -from shellgame.levels import cdpolicy, completion, solution - -DOCS = Path(__file__).resolve().parents[1] / "docs" -GUIDE = DOCS / "AUTHORING.md" - -_PREAMBLE = textwrap.dedent(""" - from shellgame.levels.base import Level - from shellgame.levels.cdpolicy import ( - CdEvidence, CdPolicy, FromDirectory, RequireAbsolutePath, RequireEvidence, - RequireExactCommand, RequireSourceDirectory, WithTarget, - ) - from shellgame.levels.collector import Section - from shellgame.levels.completion import ( - AtDirectory, AtHome, ChoiceAnswer, Completion, DirectoryExists, Evidence, - ExactAnswer, FileExists, FileLineCount, IntegerAnswer, IntegerRangeAnswer, - OrderedListAnswer, PathMoved, PathsMatch, PermissionBits, PermissionMode, - SuffixAnswer, TextFileContent, TupleAnswer, - ) - from shellgame.levels.fixture import FileFixture, WorkspaceFixture - from shellgame.levels.solution import ( - AppendFile, Chdir, Chmod, CopyPath, GoHome, MakeDirectory, MovePath, - PerformCd, RecordEvidence, RecordFdEvidence, RemovePath, RunShell, - Solution, WalkHome, WriteFile, - ) - from shellgame.markers import MarkerManager - from shellgame.paths import WORKSPACE_ROOT - - N = 99 - section = Section(99, root="level-99") -""") - -# The section template deliberately points at a file an author has yet to write. -_PLACEHOLDER_CONTENT = "sectionN_intro.md" - - -def _examples() -> list[tuple[int, str]]: - blocks = re.findall(r"```python\n(.*?)```", GUIDE.read_text(encoding="utf-8"), re.S) - return list(enumerate(blocks)) - - -@pytest.mark.parametrize(("index", "example"), _examples(), ids=[f"block-{i}" for i, _ in _examples()]) -def test_authoring_example_matches_the_real_api(index: int, example: str) -> None: - """Every documented snippet must import, construct and validate for real.""" - namespace: dict[str, Any] = {} - try: - exec(compile(_PREAMBLE + textwrap.dedent(example), f"<AUTHORING.md block {index}>", "exec"), namespace) # noqa: S102 - except FileNotFoundError as error: - if _PLACEHOLDER_CONTENT in str(error): - return - raise - - -def test_guide_documents_every_rule_the_engine_offers() -> None: - """A rule nobody documents is a rule nobody uses correctly.""" - text = GUIDE.read_text(encoding="utf-8") - undocumented = [ - f"{module.__name__.rsplit('.', 1)[1]}.{name}" - for module in (completion, cdpolicy, solution) - for name in _public_rules(module) - if name not in text - ] - assert not undocumented, "These are part of the authoring API but absent from AUTHORING.md:\n " + "\n ".join( - undocumented - ) - - -def _public_rules(module: Any) -> list[str]: - exported = getattr(module, "__all__", None) - names = exported if exported is not None else dir(module) - return [ - name - for name in names - if name[:1].isupper() - and name not in _NOT_AUTHORING_SURFACE - and getattr(getattr(module, name, None), "__module__", None) == module.__name__ - and not getattr(getattr(module, name), "_is_protocol", False) - ] - - -# Values the engine hands *to* a rule, rather than vocabulary an author writes. -_NOT_AUTHORING_SURFACE = frozenset({"CdRequest", "SolutionContext"}) - - -def test_spec_does_not_describe_commands_the_game_does_not_have() -> None: - """`docs/spec.md` is read as the source of truth, so its drift is expensive.""" - text = (DOCS / "spec.md").read_text(encoding="utf-8") - documented = set(re.findall(r"`shellgame ([a-z-]+)", text)) - documented.discard("submit") # documented with an argument placeholder - - unknown = sorted(name for name in documented if name not in cli.commands) - assert not unknown, f"docs/spec.md documents commands that do not exist: {unknown}" diff --git a/tests/test_cd_hooks_behavior.py b/tests/test_cd_hooks_behavior.py deleted file mode 100644 index 40c185a..0000000 --- a/tests/test_cd_hooks_behavior.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Tests for the generated `cd` hook dispatch. - -The wrapper body is identical for every hooked level, so what matters is that -both shells dispatch the same set of levels into it and that the body is emitted -only once. -""" - -import pytest - -from shellgame.cli.hooks import ( - generate_bash_cd_hooks, - generate_fish_cd_hooks, - get_cd_hooked_levels, -) - - -class TestCdHookGeneration: - """The shipped dispatch must cover exactly the levels that declare a hook.""" - - @pytest.mark.parametrize("level_id", ["1.7", "1.8", "1.9"]) - def test_hooked_levels_are_dispatched_in_both_shells(self, level_id: str) -> None: - assert level_id in get_cd_hooked_levels() - assert f'"{level_id}"' in _bash_patterns() - assert f'"{level_id}"' in _fish_patterns() - - @pytest.mark.parametrize("level_id", ["1.1", "2.0"]) - def test_unhooked_levels_fall_through_to_builtin_cd(self, level_id: str) -> None: - assert level_id not in get_cd_hooked_levels() - assert f'"{level_id}"' not in _bash_patterns() - assert f'"{level_id}"' not in _fish_patterns() - - def test_the_wrapper_body_is_emitted_once(self) -> None: - """One shared body; adding a hooked level must not grow the script.""" - assert generate_bash_cd_hooks().count("shellgame cd-hook --post-move") == 1 - assert generate_fish_cd_hooks().count("shellgame cd-hook --post-move") == 1 - - -def _bash_patterns() -> str: - return next( - line - for line in generate_bash_cd_hooks().splitlines() - if line.strip().startswith('"') and line.strip().endswith(")") - ) - - -def _fish_patterns() -> str: - line = next(line for line in generate_fish_cd_hooks().splitlines() if line.strip().startswith('case "')) - return line diff --git a/tests/test_cd_hooks_logic.py b/tests/test_cd_hooks_logic.py deleted file mode 100644 index ad32787..0000000 --- a/tests/test_cd_hooks_logic.py +++ /dev/null @@ -1,281 +0,0 @@ -"""Tests for Python-side cd hook logic (GameSession.handle_cd_hook).""" - -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest - -from shellgame.core.session import GameSession -from shellgame.levels.cdpolicy import cd_marker -from shellgame.levels.sections.section1 import AbsoluteCdLevel, HomeWalkLevel, MazeLevel -from shellgame.markers import MarkerManager -from shellgame.state.manager import GameState - - -@pytest.fixture -def mock_session() -> GameSession: - """Create a GameSession with mocked dependencies.""" - registry = MagicMock() - - def get_level(level_id: str): - if level_id == "1.7": - return MazeLevel() - if level_id == "1.8": - return AbsoluteCdLevel() - if level_id == "1.9": - return HomeWalkLevel() - return None - - registry.get.side_effect = get_level - - session = GameSession( - console=MagicMock(), - display=MagicMock(), - state_manager=MagicMock(), - level_registry=registry, - teleport_notice=MagicMock(), - shell_client=MagicMock(), - workspace_factory=MagicMock(), - ) - return session - - -@pytest.fixture -def mock_state(tmp_path: Path) -> GameState: - """Create a mock GameState.""" - return GameState( - username="tester", - workspace=tmp_path, - current_level="1.0", - start_time=MagicMock(), - ) - - -class TestLevel18HookLogic: - """Test Level 1.8 logic (absolute path detection).""" - - def test_detects_absolute_path(self, mock_session: GameSession, mock_state: GameState) -> None: - mock_state.current_level = "1.8" - mock_session._state_manager.load.return_value = mock_state - - with patch("shellgame.markers.MarkerManager.create") as mock_create: - # Act: cd /tmp (absolute) - mock_session.handle_cd_hook(target="/tmp", pwd="/home", post_move=False) - - # Assert: Marker created - mock_create.assert_called_with(cd_marker("1.8")) - - def test_rejects_relative_path_from_hook(self, mock_session: GameSession, mock_state: GameState) -> None: - mock_state.current_level = "1.8" - mock_session._state_manager.load.return_value = mock_state - - with pytest.raises(SystemExit) as excinfo: - # Act: cd tmp (relative) - mock_session.handle_cd_hook(target="tmp", pwd="/home", post_move=False) - assert excinfo.value.code == 1 - - def test_ignores_post_move(self, mock_session: GameSession, mock_state: GameState) -> None: - mock_state.current_level = "1.8" - mock_session._state_manager.load.return_value = mock_state - - with patch("shellgame.markers.MarkerManager.create") as mock_create: - # Act: post-move - mock_session.handle_cd_hook(target=None, pwd="/tmp", post_move=True) - - # Assert: Marker NOT created - mock_create.assert_not_called() - - def test_rejects_relative_path(self, mock_session: GameSession, mock_state: GameState) -> None: - mock_state.current_level = "1.8" - mock_session._state_manager.load.return_value = mock_state - - with pytest.raises(SystemExit) as excinfo: - mock_session.handle_cd_hook(target="relative", pwd="/home", post_move=False) - assert excinfo.value.code == 1 - - def test_rejects_empty_target(self, mock_session: GameSession, mock_state: GameState) -> None: - mock_state.current_level = "1.8" - mock_session._state_manager.load.return_value = mock_state - - with pytest.raises(SystemExit) as excinfo: - mock_session.handle_cd_hook(target="", pwd="/home", post_move=False) - assert excinfo.value.code == 1 - - -class TestLevel19HookLogic: - """Test Level 1.9 logic (step-by-step walk).""" - - def test_pre_move_rejects_jumps(self, mock_session: GameSession, mock_state: GameState) -> None: - mock_state.current_level = "1.9" - mock_session._state_manager.load.return_value = mock_state - - # Empty target (cd home) - with pytest.raises(SystemExit) as excinfo: - mock_session.handle_cd_hook(target="", pwd="/", post_move=False) - assert excinfo.value.code == 1 - - # Absolute path (not root) - with pytest.raises(SystemExit) as excinfo: - mock_session.handle_cd_hook(target="/home", pwd="/", post_move=False) - assert excinfo.value.code == 1 - - # Multi-segment relative - with pytest.raises(SystemExit) as excinfo: - mock_session.handle_cd_hook(target="a/b", pwd="/", post_move=False) - assert excinfo.value.code == 1 - - def test_pre_move_accepts_root(self, mock_session: GameSession, mock_state: GameState) -> None: - mock_state.current_level = "1.9" - mock_session._state_manager.load.return_value = mock_state - - # Should not raise - mock_session.handle_cd_hook(target="/", pwd="/home", post_move=False) - - def test_pre_move_accepts_single_segment(self, mock_session: GameSession, mock_state: GameState) -> None: - mock_state.current_level = "1.9" - mock_session._state_manager.load.return_value = mock_state - - # Should not raise - mock_session.handle_cd_hook(target="home", pwd="/", post_move=False) - mock_session.handle_cd_hook(target="home/", pwd="/", post_move=False) - - def test_starts_tracking_at_root(self, mock_session: GameSession, mock_state: GameState) -> None: - mock_state.current_level = "1.9" - mock_session._state_manager.load.return_value = mock_state - - with patch("shellgame.markers.MarkerManager.create") as mock_create: - # Act: cd / (post-move) - mock_session.handle_cd_hook(target=None, pwd="/", post_move=True) - - # Assert: Progress initialized - mock_create.assert_called_with(MarkerManager.LEVEL1_9_CD_WALK_PROGRESS, "/") - - def test_tracks_valid_step(self, mock_session: GameSession, mock_state: GameState) -> None: - mock_state.current_level = "1.9" - mock_session._state_manager.load.return_value = mock_state - - with ( - patch("shellgame.markers.MarkerManager.read") as mock_read, - patch("shellgame.markers.MarkerManager.create") as mock_create, - ): - # Setup: Previously at / - mock_read.return_value = "/" - - # Act: cd /home (post-move) - mock_session.handle_cd_hook(target=None, pwd="/home", post_move=True) - - # Assert: Progress updated - mock_create.assert_called_with(MarkerManager.LEVEL1_9_CD_WALK_PROGRESS, "/\n/home") - - def test_completes_at_home(self, mock_session: GameSession, mock_state: GameState) -> None: - mock_state.current_level = "1.9" - mock_session._state_manager.load.return_value = mock_state - - # Mock Path.home() to match our destination - fake_home = Path("/home/tester") - - with ( - patch("shellgame.markers.MarkerManager.read") as mock_read, - patch("shellgame.markers.MarkerManager.create") as mock_create, - patch("pathlib.Path.home", return_value=fake_home), - ): - # Setup: Previously at /home - mock_read.return_value = "/\n/home" - - # Act: cd /home/tester (post-move) - mock_session.handle_cd_hook(target=None, pwd="/home/tester", post_move=True) - - # Assert: Progress updated AND completion marker created - # Note: create is called twice - assert mock_create.call_count == 2 - mock_create.assert_any_call(MarkerManager.LEVEL1_9_CD_WALK_PROGRESS, "/\n/home\n/home/tester") - mock_create.assert_any_call(MarkerManager.LEVEL1_9_CD_WALK_COMPLETED) - - def test_resets_on_invalid_step(self, mock_session: GameSession, mock_state: GameState) -> None: - mock_state.current_level = "1.9" - mock_session._state_manager.load.return_value = mock_state - - with ( - patch("shellgame.markers.MarkerManager.read") as mock_read, - patch("shellgame.markers.MarkerManager.remove") as mock_remove, - ): - # Setup: Previously at / - mock_read.return_value = "/" - - # Act: cd /var/log (jump, skipping /var) - mock_session.handle_cd_hook(target=None, pwd="/var/log", post_move=True) - - # Assert: Progress removed - mock_remove.assert_called_with(MarkerManager.LEVEL1_9_CD_WALK_PROGRESS) - - def test_ignores_pre_move(self, mock_session: GameSession, mock_state: GameState) -> None: - mock_state.current_level = "1.9" - mock_session._state_manager.load.return_value = mock_state - - with patch("shellgame.markers.MarkerManager.create") as mock_create: - # Act: pre-move - mock_session.handle_cd_hook(target="/", pwd="/home", post_move=False) - - # Assert: Nothing happens - mock_create.assert_not_called() - - -class TestLevel17HookLogic: - """Test Level 1.7 logic (maze boundary guard and autowin).""" - - def test_pre_move_blocks_leaving_maze(self, mock_session: GameSession, mock_state: GameState) -> None: - mock_state.current_level = "1.7" - mock_session._state_manager.load.return_value = mock_state - - level = MazeLevel() - level.prepare(mock_state.workspace) - - # cd /etc or outside maze should be blocked - with pytest.raises(SystemExit) as excinfo: - mock_session.handle_cd_hook( - target="/etc", - pwd=str(mock_state.workspace / "level-1" / "maze" / "entry"), - post_move=False, - ) - assert excinfo.value.code == 1 - - def test_pre_move_allows_returning_to_start(self, mock_session: GameSession, mock_state: GameState) -> None: - mock_state.current_level = "1.7" - mock_session._state_manager.load.return_value = mock_state - - level = MazeLevel() - level.prepare(mock_state.workspace) - start_dir = mock_state.workspace / "level-1" / "maze" / "entry" - - # Moving to start_dir is always allowed - mock_session.handle_cd_hook( - target=str(start_dir), - pwd=str(mock_state.workspace / "level-1" / "maze" / "nexus"), - post_move=False, - ) - - def test_pre_move_allows_navigation_within_maze(self, mock_session: GameSession, mock_state: GameState) -> None: - mock_state.current_level = "1.7" - mock_session._state_manager.load.return_value = mock_state - - level = MazeLevel() - level.prepare(mock_state.workspace) - - # cd nexus inside maze - mock_session.handle_cd_hook( - target="nexus", - pwd=str(mock_state.workspace / "level-1" / "maze"), - post_move=False, - ) - - def test_post_move_autowin_on_final(self, mock_session: GameSession, mock_state: GameState) -> None: - mock_state.current_level = "1.7" - mock_session._state_manager.load.return_value = mock_state - - level = MazeLevel() - level.prepare(mock_state.workspace) - - sanctuary = mock_state.workspace / "level-1" / level._SANCTUARY - with patch("shellgame.markers.MarkerManager.create") as mock_create: - mock_session.handle_cd_hook(target=None, pwd=str(sanctuary), post_move=True) - mock_create.assert_called_with(cd_marker("1.7")) diff --git a/tests/test_cd_hooks_no_softlock.py b/tests/test_cd_hooks_no_softlock.py deleted file mode 100644 index d683372..0000000 --- a/tests/test_cd_hooks_no_softlock.py +++ /dev/null @@ -1,175 +0,0 @@ -"""Anti-soft-lock tests for strict `cd` hooks. - -Several navigation levels only accept one exact `cd` command. Before these -guarantees existed, a player who took one wrong step could end up in a -directory where *every* permitted move led further away, with no in-game way -back - the level became unwinnable without deleting the whole workspace. - -The rules asserted here are: - -1. A blocked move always tells the player how to recover. -2. A move back to the level start directory is always allowed. -3. Once the required command has been demonstrated (evidence marker written), - free movement is restored - the drill is over. - -Completion is still gated by each level's declarative requirements, so lifting -the *movement* restriction never lets a player pass a level they did not solve. -""" - -from __future__ import annotations - -import inspect -import io -import sys -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any, NamedTuple - -import pytest - -from shellgame.levels.cdpolicy import cd_marker -from shellgame.levels.loader import initialize_levels -from shellgame.levels.registry import get_registry -from shellgame.markers import MarkerManager -from shellgame.messages import Messages - - -class _Case(NamedTuple): - level_id: str - marker: str - #: a `cd` target this level rejects while the drill is unfinished - rejected_target: str - #: markers that must already exist for the level to reach its strict branch - prerequisites: tuple[str, ...] = () - #: cwd (relative to workspace) the hook must see for its strict branch - pwd_relative: str | None = None - - -STRICT_CD_CASES = [ - _Case("1.4", cd_marker("1.4"), rejected_target="/etc"), - _Case("1.6", cd_marker("1.6"), rejected_target="/etc"), - _Case("1.7", cd_marker("1.7"), rejected_target="/etc"), - _Case("1.8", cd_marker("1.8"), rejected_target="relative/path"), - _Case("1.9", MarkerManager.LEVEL1_9_CD_WALK_COMPLETED, rejected_target="/etc"), - _Case( - "1.12", - cd_marker("1.12"), - rejected_target="/etc", - prerequisites=(MarkerManager.PWD_USED,), - pwd_relative="level-1/gamma/deep/a/b/c", - ), - _Case("2.3", cd_marker("2.3"), rejected_target="/etc"), -] - -_IDS = [case.level_id for case in STRICT_CD_CASES] - - -@dataclass -class _FakeState: - workspace: Path - username: str = "student" - current_level: str = "1.1" - level_hints_used: dict[str, int] = field(default_factory=dict) - - -@pytest.fixture(scope="module") -def registry() -> Any: - initialize_levels() - return get_registry() - - -def _run_hook(level: Any, state: _FakeState, target: str | None, pwd: str) -> tuple[bool, str]: - """Run a level's cd hook. Returns ``(blocked, stderr_text)``.""" - hook = level.hooks.get("cd") - assert hook is not None, f"level {level.id} has no cd hook" - - captured = io.StringIO() - original = sys.stderr - sys.stderr = captured - try: - hook(target=target, pwd=pwd, post_move=False, state=state) - except SystemExit: - return True, captured.getvalue() - finally: - sys.stderr = original - return False, captured.getvalue() - - -def _setup(registry: Any, tmp_path: Path, case: _Case) -> tuple[Any, _FakeState, Path, Path]: - level = registry.get(case.level_id) - assert level is not None, f"unknown level {case.level_id}" - state = _FakeState(workspace=tmp_path, current_level=case.level_id) - level.prepare(tmp_path) - - markers = MarkerManager.from_state(state) - for prerequisite in case.prerequisites: - markers.create(prerequisite) - - start = level.get_start_directory(tmp_path) - assert start is not None - start.mkdir(parents=True, exist_ok=True) - - strict_pwd = tmp_path / case.pwd_relative if case.pwd_relative else start - strict_pwd.mkdir(parents=True, exist_ok=True) - - return level, state, start, strict_pwd - - -@pytest.mark.parametrize("case", STRICT_CD_CASES, ids=_IDS) -class TestStrictCdHooksNeverSoftLock: - def test_blocked_move_explains_how_to_recover(self, registry: Any, tmp_path: Path, case: _Case) -> None: - level, state, _start, strict_pwd = _setup(registry, tmp_path, case) - - blocked, err = _run_hook(level, state, target=case.rejected_target, pwd=str(strict_pwd)) - - assert blocked, f"{case.level_id} unexpectedly allowed cd {case.rejected_target}" - assert Messages.CD_RECOVERY_TIP in err - assert "shellgame reset" in err - - def test_returning_to_the_start_directory_is_always_allowed( - self, registry: Any, tmp_path: Path, case: _Case - ) -> None: - level, state, start, _strict_pwd = _setup(registry, tmp_path, case) - - stray = tmp_path / "somewhere-far-away" - stray.mkdir(parents=True, exist_ok=True) - - blocked, _ = _run_hook(level, state, target=str(start), pwd=str(stray)) - - assert not blocked, f"{case.level_id} blocked the move back to its own start directory" - - def test_movement_is_free_once_the_skill_is_demonstrated(self, registry: Any, tmp_path: Path, case: _Case) -> None: - level, state, _start, strict_pwd = _setup(registry, tmp_path, case) - - blocked_before, _ = _run_hook(level, state, target=case.rejected_target, pwd=str(strict_pwd)) - assert blocked_before, f"{case.level_id} is not actually strict; the test case is stale" - - # The player demonstrates the required command. - MarkerManager.from_state(state).create(case.marker) - - blocked_after, _ = _run_hook(level, state, target=case.rejected_target, pwd=str(strict_pwd)) - assert not blocked_after, f"{case.level_id} kept restricting movement after the drill was completed" - - -def test_every_blocking_level_is_covered() -> None: - """Guard against a new strict hook being added without soft-lock protection.""" - covered = {case.level_id for case in STRICT_CD_CASES} - # `2.2` only intercepts the literal `cd -`; every other move stays free, - # so it cannot strand a player and needs no recovery rule. - known_narrow = {"2.2"} - - initialize_levels() - for level in get_registry().list_levels(): - if not _can_reject_a_move(level): - continue - assert level.id in covered or level.id in known_narrow, ( - f"Level {level.id} rejects cd but has no soft-lock test coverage" - ) - - -def _can_reject_a_move(level: Any) -> bool: - """A level rejects moves via a declared `cd_policy` or a hand-written hook.""" - if getattr(level, "cd_policy", None) is not None: - return True - handler = getattr(level, "_handle_cd", None) - return handler is not None and "block_cd" in inspect.getsource(handler) diff --git a/tests/test_cli_level_start_directory.py b/tests/test_cli_level_start_directory.py deleted file mode 100644 index ae83a9d..0000000 --- a/tests/test_cli_level_start_directory.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Unit tests for CLI helper functions. - -These tests lock in path-mapping behavior so it doesn't regress. -""" - -import os -from pathlib import Path -from unittest.mock import MagicMock - -from shellgame.cli.commands import level_registry -from shellgame.core.navigation import NavigationManager -from shellgame.levels.sections.section1 import PwdLevel - - -def test_get_level_start_directory_selected_levels(tmp_path: Path) -> None: - workspace = tmp_path / "workspace" - - # Intro levels start in the workspace root. - assert level_registry.get("0.0").get_start_directory(workspace) == workspace - - # A representative "starts somewhere specific" level. - assert level_registry.get("1.4").get_start_directory(workspace) == workspace / "level-1" / "alpha" - - # 1.5 is a navigation task; it must not start at the goal directory. - assert level_registry.get("1.5").get_start_directory(workspace) == workspace / "level-1" - - # Section intro levels ("x.0") default to "no forced start dir". - assert level_registry.get("2.0").get_start_directory(workspace) is None - - # Unknown levels also default to "no forced start dir". - assert level_registry.get("999.9") is None - - -def test_failed_local_teleport_is_delegated_without_notice(tmp_path: Path, monkeypatch) -> None: - shell_client = MagicMock() - teleport_notice = MagicMock() - navigation = NavigationManager(level_registry, shell_client, teleport_notice) - destination = tmp_path / "missing" - monkeypatch.setattr(os, "chdir", MagicMock(side_effect=FileNotFoundError(destination))) - - navigation.maybe_teleport(destination) - - shell_client.cd.assert_called_once_with(destination) - teleport_notice.assert_not_called() - - -def test_level1_1_requires_pwd_marker(tmp_path: Path) -> None: - """Level 1.1 should require evidence that `pwd` was used.""" - - # Fake state object with just the fields Level1_1.validate expects. - class _State: - def __init__(self, username: str, workspace: Path) -> None: - self.username = username - self.workspace = workspace - self.current_level = "1.1" - - state = _State(username="testuser", workspace=tmp_path / "workspace") - state.workspace.mkdir() - marker = state.workspace / ".pwd_used" - - level = PwdLevel() - - ok, msg = level.validate("level-1", state) - assert ok is False - assert "pwd" in msg - - marker.parent.mkdir(parents=True, exist_ok=True) - marker.write_text("") - try: - ok2, _ = level.validate("level-1", state) - assert ok2 is True - finally: - marker.unlink(missing_ok=True) - - -def test_level1_1_wrong_answer_does_not_spoil_expected(tmp_path: Path) -> None: - """If the pwd marker exists, a wrong answer should fail without revealing the expected value.""" - - class _State: - def __init__(self, username: str, workspace: Path) -> None: - self.username = username - self.workspace = workspace - self.current_level = "1.1" - - state = _State(username="testuser", workspace=tmp_path / "workspace") - state.workspace.mkdir() - marker = state.workspace / ".pwd_used" - marker.write_text("") - - level = PwdLevel() - try: - ok, msg = level.validate("shellgame-viper", state) - assert ok is False - assert "Očekáváno" not in msg - assert "level-1" not in msg - finally: - marker.unlink(missing_ok=True) diff --git a/tests/test_cli_repeat_and_reset.py b/tests/test_cli_repeat_and_reset.py deleted file mode 100644 index 78dd620..0000000 --- a/tests/test_cli_repeat_and_reset.py +++ /dev/null @@ -1,161 +0,0 @@ -"""Tests for CLI commands `repeat` and `reset`. - -We patch the global singletons in `shellgame.cli.commands` to avoid -filesystem side-effects and to assert the right display calls happen. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from pathlib import Path -from typing import Any - -from shellgame.cli import commands - - -@dataclass -class _FakeState: - username: str - workspace: Path - current_level: str - - -class _FakeLevel: - def __init__(self, level_id: str, *, enforce_start_directory: bool = False) -> None: - self.id = level_id - self.reset_called_with: list[Path] = [] - self.prepare_called_with: list[Path] = [] - self.enforce_start_directory = enforce_start_directory - self.start_directory = "" - - def reset(self, workspace: Path) -> None: - self.reset_called_with.append(workspace) - - def prepare(self, workspace: Path) -> None: - self.prepare_called_with.append(workspace) - - def get_start_directory(self, workspace: Path) -> Path: - return workspace / self.start_directory if self.start_directory else workspace - - -class _FakeRegistry: - def __init__(self, levels: dict[str, _FakeLevel]) -> None: - self._levels = levels - - def get(self, level_id: str) -> _FakeLevel | None: - return self._levels.get(level_id) - - -class _FakeDisplay: - def __init__(self) -> None: - self.reset_calls: list[str] = [] - self.instructions_calls: list[str] = [] - self.workspace_restored_calls: list[Path] = [] - - def show_workspace_restored(self, workspace: Path) -> None: - self.workspace_restored_calls.append(workspace) - - def show_reset(self, level_id: str) -> None: - self.reset_calls.append(level_id) - - def show_instructions(self, level: _FakeLevel) -> None: # type: ignore[override] - self.instructions_calls.append(level.id) - - def show_not_initialized(self) -> None: - raise AssertionError("should not be called in these tests") - - -def test_reset_shows_assignment_again(monkeypatch: Any, tmp_path: Path) -> None: - state = _FakeState(username="u", workspace=tmp_path / "ws", current_level="1.2") - level = _FakeLevel("1.2") - - fake_display = _FakeDisplay() - fake_registry = _FakeRegistry({"1.2": level}) - - monkeypatch.setattr(commands, "display", fake_display) - monkeypatch.setattr(commands, "level_registry", fake_registry) - monkeypatch.setattr(commands.state_manager, "load", lambda: state) - - assert commands.reset.callback is not None - commands.reset.callback() - - assert level.reset_called_with == [state.workspace] - assert fake_display.reset_calls == ["1.2"] - assert fake_display.instructions_calls == ["1.2"] - - -def test_reset_teleports_lost_player_back_to_start(monkeypatch: Any, tmp_path: Path) -> None: - """`reset` must rescue a player who wandered outside the level start directory.""" - workspace = tmp_path / "ws" - start = workspace / "level-1" - start.mkdir(parents=True) - elsewhere = tmp_path / "elsewhere" - elsewhere.mkdir() - - state = _FakeState(username="u", workspace=workspace, current_level="1.4") - level = _FakeLevel("1.4", enforce_start_directory=True) - level.start_directory = "level-1" - - fake_display = _FakeDisplay() - fake_registry = _FakeRegistry({"1.4": level}) - - monkeypatch.setattr(commands, "display", fake_display) - monkeypatch.setattr(commands, "level_registry", fake_registry) - monkeypatch.setattr(commands.state_manager, "load", lambda: state) - monkeypatch.chdir(elsewhere) - - assert commands.reset.callback is not None - commands.reset.callback() - - assert Path.cwd().resolve() == start.resolve() - - -def test_repeat_defaults_to_current_level(monkeypatch: Any, tmp_path: Path) -> None: - state = _FakeState(username="u", workspace=tmp_path / "ws", current_level="1.7") - level = _FakeLevel("1.7") - - fake_display = _FakeDisplay() - fake_registry = _FakeRegistry({"1.7": level}) - - monkeypatch.setattr(commands, "display", fake_display) - monkeypatch.setattr(commands, "level_registry", fake_registry) - monkeypatch.setattr(commands.state_manager, "load", lambda: state) - - assert commands.repeat.callback is not None - commands.repeat.callback(section_num=None, level_id=None) - - assert fake_display.instructions_calls == ["1.7"] - - -def test_repeat_by_section_uses_intro_level(monkeypatch: Any, tmp_path: Path) -> None: - state = _FakeState(username="u", workspace=tmp_path / "ws", current_level="9.9") - level = _FakeLevel("2.0") - - fake_display = _FakeDisplay() - fake_registry = _FakeRegistry({"2.0": level}) - - monkeypatch.setattr(commands, "display", fake_display) - monkeypatch.setattr(commands, "level_registry", fake_registry) - monkeypatch.setattr(commands.state_manager, "load", lambda: state) - - assert commands.repeat.callback is not None - commands.repeat.callback(section_num=2, level_id=None) - - assert fake_display.instructions_calls == ["2.0"] - - -def test_repeat_by_level_overrides_section(monkeypatch: Any, tmp_path: Path) -> None: - state = _FakeState(username="u", workspace=tmp_path / "ws", current_level="9.9") - level = _FakeLevel("1.1") - - fake_display = _FakeDisplay() - fake_registry = _FakeRegistry({"1.1": level, "2.0": _FakeLevel("2.0")}) - - monkeypatch.setattr(commands, "display", fake_display) - monkeypatch.setattr(commands, "level_registry", fake_registry) - monkeypatch.setattr(commands.state_manager, "load", lambda: state) - - assert commands.repeat.callback is not None - commands.repeat.callback(section_num=2, level_id="1.1") - - assert fake_display.instructions_calls == ["1.1"] diff --git a/tests/test_cli_subshell_launch.py b/tests/test_cli_subshell_launch.py deleted file mode 100644 index 216a756..0000000 --- a/tests/test_cli_subshell_launch.py +++ /dev/null @@ -1,241 +0,0 @@ -"""Tests for subshell launching behavior. - -CRITICAL REQUIREMENTS (do not break): - -- bash must be launched with: - `bash --rcfile <integration_script> -i` - - NEVER use --norc (it disables --rcfile entirely!) - NEVER use --noprofile (let users keep their PATH/env setup) - - The integration script is used directly as the rcfile - there is NO separate - rc template. Autostart is at the END of the integration template. - -- fish must suppress greeting and source the integration script: - `fish --init-command "function fish_greeting; end; source <script>"` - - Autostart is inside the integration template (not in init-command). - -Both templates follow the same pattern: -1. Shell setup -2. Function definitions (shellgame, pwd, cd hooks) -3. Autostart block at the END (after functions are defined) - -These tests intentionally do not spawn real shells; they assert the subprocess -invocations and temporary file behavior. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Any - -from shellgame.cli import subshell - - -@dataclass -class _RunCall: - args: list[str] - env: dict[str, str] | None - - -@dataclass -class _Completed: - returncode: int = 0 - - -class _FakeNamedTemp: - """Minimal tempfile.NamedTemporaryFile stand-in. - - Supports context-manager protocol and provides: - - `.name` path - - `.write(str)` for collecting written content - """ - - def __init__(self, name: str) -> None: - self.name = name - self._writes: list[str] = [] - - def __enter__(self) -> _FakeNamedTemp: - return self - - def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None: - return None - - def write(self, s: str) -> int: - self._writes.append(s) - return len(s) - - @property - def content(self) -> str: - return "".join(self._writes) - - -class _TempFactory: - """Factory that returns deterministic fake tempfiles in call order.""" - - def __init__(self) -> None: - self.created: list[_FakeNamedTemp] = [] - self._counter = 0 - - def __call__(self, *args: Any, **kwargs: Any) -> _FakeNamedTemp: # noqa: ANN401 - self._counter += 1 - suffix = kwargs.get("suffix", "") - name = f"/tmp/fake-tmp-{self._counter}{suffix}" - f = _FakeNamedTemp(name) - self.created.append(f) - return f - - -class _UnlinkRecorder: - def __init__(self) -> None: - self.paths: list[str] = [] - - def __call__(self, path: str) -> None: - self.paths.append(path) - - -class _Console: - def __init__(self) -> None: - self.printed: list[str] = [] - - def print(self, msg: str) -> None: - self.printed.append(msg) - - -def test_launch_subshell_bash_critical_flags(monkeypatch: Any) -> None: - """CRITICAL: Verify bash is launched correctly. - - - Must use --rcfile with integration script directly - - Must NOT use --norc (disables --rcfile!) - - Must NOT use --noprofile (breaks user PATH/env) - - Must use -i for interactive mode - """ - calls: list[_RunCall] = [] - temp_factory = _TempFactory() - unlink = _UnlinkRecorder() - console = _Console() - - def fake_run(args: list[str], env: dict[str, str] | None = None, **kwargs: Any) -> _Completed: - calls.append(_RunCall(args=args, env=env)) - return _Completed() - - monkeypatch.setattr(subshell.tempfile, "NamedTemporaryFile", temp_factory) - monkeypatch.setattr(subshell.subprocess, "run", fake_run) - monkeypatch.setattr(subshell.os, "chmod", lambda *_: None) - monkeypatch.setattr(subshell.os, "unlink", unlink) - monkeypatch.setattr(subshell.os.path, "abspath", lambda _: "/fake/shellgame") - monkeypatch.setattr(subshell.sys, "argv", ["/fake/shellgame"]) - monkeypatch.setattr(subshell.os, "environ", {"USER": "u", "PATH": "/bin"}) - - subshell.launch_subshell("bash", devmode=False) - - # Integration script plus child-safe fd-hook launcher. - assert len(temp_factory.created) == 2 - script_tmp = temp_factory.created[0] - fd_hook_tmp = temp_factory.created[1] - - # bash invocation: no special suppression flags. - # NOTE: We do NOT use --norc (disables --rcfile) or --noprofile (breaks user PATH/env). - assert len(calls) == 1 - - args = calls[0].args - assert args[0] == "bash" - assert "--norc" not in args - assert "--noprofile" not in args - - # Must include: --rcfile <script_tmp.name> and -i - assert "-i" in args - - rc_idx = args.index("--rcfile") - assert args[rc_idx + 1] == script_tmp.name - - # Ensure env is passed and wrapper flag is set. - assert calls[0].env is not None - assert calls[0].env.get("SHELLGAME_WRAPPER") == "1" - assert calls[0].env.get("SHELLGAME_FD_HOOK") == fd_hook_tmp.name - - # Temp files are cleaned up. - assert unlink.paths == [script_tmp.name, fd_hook_tmp.name] - - # console should not be used for supported shell flow - assert console.printed == [] - - -def test_launch_subshell_fish_suppresses_greeting_and_sources_script( - monkeypatch: Any, -) -> None: - calls: list[_RunCall] = [] - temp_factory = _TempFactory() - unlink = _UnlinkRecorder() - console = _Console() - - def fake_run(args: list[str], env: dict[str, str] | None = None, **kwargs: Any) -> _Completed: - calls.append(_RunCall(args=args, env=env)) - return _Completed() - - monkeypatch.setattr(subshell.tempfile, "NamedTemporaryFile", temp_factory) - monkeypatch.setattr(subshell.subprocess, "run", fake_run) - monkeypatch.setattr(subshell.os, "chmod", lambda *_: None) - monkeypatch.setattr(subshell.os, "unlink", unlink) - monkeypatch.setattr(subshell.os.path, "abspath", lambda _: "/fake/shellgame") - monkeypatch.setattr(subshell.sys, "argv", ["/fake/shellgame"]) - monkeypatch.setattr(subshell.os, "environ", {"USER": "u", "PATH": "/bin"}) - - subshell.launch_subshell("fish", devmode=False) - - # Integration script plus child-safe fd-hook launcher. - assert len(temp_factory.created) == 2 - script_tmp = temp_factory.created[0] - fd_hook_tmp = temp_factory.created[1] - - assert len(calls) == 1 - assert calls[0].args[0:2] == ["fish", "--init-command"] - - init_cmd = calls[0].args[2] - # Must override fish_greeting and source integration. - # Autostart is now handled inside the integration template (not in init_cmd). - assert "function fish_greeting; end;" in init_cmd - assert f"source {script_tmp.name}" in init_cmd - - # Ensure env is passed and wrapper flag is set. - assert calls[0].env is not None - assert calls[0].env.get("SHELLGAME_WRAPPER") == "1" - assert calls[0].env.get("SHELLGAME_FD_HOOK") == fd_hook_tmp.name - - # Temp files are cleaned up. - assert unlink.paths == [script_tmp.name, fd_hook_tmp.name] - - assert console.printed == [] - - -def test_launch_subshell_unknown_shell_raises_and_cleans_script(monkeypatch: Any) -> None: - calls: list[_RunCall] = [] - temp_factory = _TempFactory() - unlink = _UnlinkRecorder() - - def fake_run(args: list[str], env: dict[str, str] | None = None, **kwargs: Any) -> None: - calls.append(_RunCall(args=args, env=env)) - - monkeypatch.setattr(subshell.tempfile, "NamedTemporaryFile", temp_factory) - monkeypatch.setattr(subshell.subprocess, "run", fake_run) - monkeypatch.setattr(subshell.os, "chmod", lambda *_: None) - monkeypatch.setattr(subshell.os, "unlink", unlink) - monkeypatch.setattr(subshell.os.path, "abspath", lambda _: "/fake/shellgame") - monkeypatch.setattr(subshell.sys, "argv", ["/fake/shellgame"]) - monkeypatch.setattr(subshell.os, "environ", {"USER": "u", "PATH": "/bin"}) - - try: - subshell.launch_subshell("zsh", devmode=False) - raise AssertionError("expected ValueError") - except ValueError as e: - assert "Nepodporovaný shell" in str(e) - - # No subprocess call for unsupported shell. - assert calls == [] - - # Both tempfiles are still created and cleaned up. - assert len(temp_factory.created) == 2 - script_tmp = temp_factory.created[0] - fd_hook_tmp = temp_factory.created[1] - assert unlink.paths == [script_tmp.name, fd_hook_tmp.name] diff --git a/tests/test_cwd_resilience.py b/tests/test_cwd_resilience.py deleted file mode 100644 index bd694c1..0000000 --- a/tests/test_cwd_resilience.py +++ /dev/null @@ -1,168 +0,0 @@ -"""The game must survive the player deleting the directory they stand in. - -Sections 3 and 4 teach `rm`, `rmdir` and `mv`, so a player can delete their own -working directory. After that `getcwd()` fails for every process the shell -spawns. Nothing may escape as a traceback. -""" - -from __future__ import annotations - -import os -from collections.abc import Iterator -from datetime import datetime -from pathlib import Path - -import click -import pytest -from click.testing import CliRunner - -from shellgame.cli import commands -from shellgame.core.navigation import NavigationManager -from shellgame.levels.base import Level -from shellgame.levels.completion import AtDirectory, AtHome, Completion -from shellgame.messages import Messages -from shellgame.paths import current_directory -from shellgame.state.manager import GameState - - -@pytest.fixture -def deleted_cwd(tmp_path: Path) -> Iterator[None]: - """Stand inside a directory and then delete it.""" - original = Path.cwd() - doomed = tmp_path / "doomed" - doomed.mkdir() - os.chdir(doomed) - doomed.rmdir() - try: - yield - finally: - os.chdir(original) - - -def _state(workspace: Path) -> GameState: - return GameState( - username="tester", - workspace=workspace, - current_level="1.1", - start_time=datetime.now(), - ) - - -def test_current_directory_returns_none_when_cwd_is_gone(deleted_cwd: None) -> None: - assert current_directory() is None - - -def test_current_directory_resolves_a_live_directory(tmp_path: Path) -> None: - assert current_directory() is not None - - -def test_at_directory_reports_missing_cwd_instead_of_raising(tmp_path: Path, deleted_cwd: None) -> None: - workspace = tmp_path / "workspace" - (workspace / "level-1").mkdir(parents=True) - - success, message = AtDirectory("level-1").check(_state(workspace), workspace) - - assert success is False - assert message == Messages.CWD_MISSING - - -def test_at_home_reports_missing_cwd_instead_of_raising(tmp_path: Path, deleted_cwd: None) -> None: - success, message = AtHome().check(_state(tmp_path), tmp_path) - - assert success is False - assert message == Messages.CWD_MISSING - - -def test_completion_validate_survives_missing_cwd(tmp_path: Path, deleted_cwd: None) -> None: - workspace = tmp_path / "workspace" - (workspace / "level-1").mkdir(parents=True) - completion = Completion(requirements=(AtDirectory("level-1"),)) - - success, message = completion.validate(None, _state(workspace), root=workspace, success_message="ok") - - assert success is False - assert message == Messages.CWD_MISSING - - -def test_returns_to_start_is_false_when_cwd_is_gone(tmp_path: Path, deleted_cwd: None) -> None: - class _Level(Level): - title = "Relative move" - completion = Completion() - start_directory = "level-1" - - level = _Level() - - # A relative target with no `pwd` must resolve against the cwd, which is gone. - assert level.returns_to_start(target="level-1", pwd=None, state=_state(tmp_path)) is False - - -def test_returns_to_start_still_works_when_pwd_is_supplied(tmp_path: Path, deleted_cwd: None) -> None: - class _Level(Level): - title = "Relative move" - completion = Completion() - start_directory = "level-1" - - workspace = tmp_path / "workspace" - (workspace / "level-1").mkdir(parents=True) - level = _Level() - - assert level.returns_to_start(target="level-1", pwd=str(workspace), state=_state(workspace)) is True - - -@pytest.mark.parametrize("cwd_missing", [False, True]) -@pytest.mark.parametrize( - "error", - [ - FileNotFoundError(2, "No such file or directory"), - IsADirectoryError(21, "Is a directory", "pass.txt"), - PermissionError(13, "Permission denied", "pass.txt"), - ], -) -def test_cli_renders_os_errors_instead_of_a_traceback( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, cwd_missing: bool, error: OSError -) -> None: - shown: list[str] = [] - - class _Display: - def show_state_error(self, message: str) -> None: - shown.append(message) - - monkeypatch.setattr(commands, "display", _Display()) - monkeypatch.setattr(commands, "current_directory", lambda: None if cwd_missing else tmp_path) - - @click.group(cls=commands.CzechGroup) - def group() -> None: ... - - @group.command() - def boom() -> None: - raise error - - result = CliRunner().invoke(group, ["boom"]) - - assert result.exit_code == 1 - assert shown and str(error) in shown[0] - if cwd_missing: - assert Messages.CWD_MISSING in shown[0] - else: - assert shown[0] == Messages.FILESYSTEM_ERROR.format(error=error) - assert Messages.CWD_MISSING not in shown[0] - - -def test_navigation_teleports_when_cwd_is_gone(tmp_path: Path, deleted_cwd: None) -> None: - class _Shell: - def __init__(self) -> None: - self.destinations: list[Path] = [] - - def cd(self, destination: Path) -> None: - self.destinations.append(destination) - - notices: list[Path] = [] - shell = _Shell() - manager = NavigationManager(None, shell, notices.append) # type: ignore[arg-type] - - target = tmp_path / "start" - target.mkdir() - manager.maybe_teleport(target) - - assert shell.destinations == [target] - assert notices == [target] diff --git a/tests/test_declarative_levels.py b/tests/test_declarative_levels.py deleted file mode 100644 index 5fe8be3..0000000 --- a/tests/test_declarative_levels.py +++ /dev/null @@ -1,544 +0,0 @@ -import shutil -import stat -from collections.abc import Callable -from datetime import datetime -from pathlib import Path - -import pytest - -from shellgame.levels.base import Level -from shellgame.levels.collector import Section -from shellgame.levels.completion import ( - AtDirectory, - ChoiceAnswer, - Completion, - DirectoryExists, - Evidence, - ExactAnswer, - FileExists, - FileLineCount, - IntegerAnswer, - IntegerRangeAnswer, - OrderedListAnswer, - PathMoved, - PathsMatch, - PermissionBits, - PermissionMode, - SuffixAnswer, - TextFileContent, - TupleAnswer, -) -from shellgame.levels.fixture import FileFixture, WorkspaceFixture -from shellgame.levels.loader import initialize_levels -from shellgame.levels.registry import get_registry -from shellgame.markers import MarkerManager -from shellgame.messages import Messages -from shellgame.state.manager import GameState - - -def _state(workspace: Path) -> GameState: - return GameState( - username="tester", - workspace=workspace, - current_level="test", - start_time=datetime.now(), - ) - - -class _FixtureLevel(Level): - title = "Fixture test" - completion = Completion() - section_root = "section" - section_fixture = WorkspaceFixture( - files=( - FileFixture("ordered.txt", "section"), - FileFixture("persistent.txt", "initial", overwrite=False), - ), - ) - fixture = WorkspaceFixture(files=(FileFixture("ordered.txt", "level"),)) - - -def test_level_prepare_applies_section_then_level_fixture(tmp_path: Path) -> None: - level = _FixtureLevel() - - level.prepare(tmp_path) - - root = tmp_path / "section" - assert (root / "ordered.txt").read_text(encoding="utf-8") == "level" - - -def test_fixture_overwrite_policy_preserves_shared_state(tmp_path: Path) -> None: - level = _FixtureLevel() - level.prepare(tmp_path) - root = tmp_path / "section" - (root / "persistent.txt").write_text("player change", encoding="utf-8") - (root / "ordered.txt").write_text("player change", encoding="utf-8") - - level.prepare(tmp_path) - - assert (root / "persistent.txt").read_text(encoding="utf-8") == "player change" - assert (root / "ordered.txt").read_text(encoding="utf-8") == "level" - - -@pytest.mark.parametrize("overwrite", [False, True]) -@pytest.mark.parametrize("content", ["restored", b"restored"]) -def test_file_fixture_replaces_an_accidental_directory(tmp_path: Path, overwrite: bool, content: str | bytes) -> None: - root = tmp_path / "fixture" - root.mkdir() - target = root / "input.txt" - target.mkdir() - (target / "mistake.txt").write_text("mistake", encoding="utf-8") - outside = tmp_path / "outside" - outside.mkdir() - keep = outside / "keep.txt" - keep.write_text("keep", encoding="utf-8") - (target / "link").symlink_to(outside, target_is_directory=True) - - FileFixture("input.txt", content, overwrite=overwrite, mode=0o640).apply(root) - - assert target.is_file() - assert target.read_bytes() == b"restored" - assert stat.S_IMODE(target.stat().st_mode) == 0o640 - assert keep.read_text(encoding="utf-8") == "keep" - - -@pytest.mark.parametrize("blocker", ["nested", "nested/deep"]) -@pytest.mark.parametrize("file_fixture", [False, True]) -def test_fixture_replaces_files_blocking_expected_directories(tmp_path: Path, blocker: str, file_fixture: bool) -> None: - target = tmp_path / blocker - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text("wrong type", encoding="utf-8") - - if file_fixture: - FileFixture("nested/deep/input.txt", "restored").apply(tmp_path) - assert (tmp_path / "nested/deep/input.txt").read_text(encoding="utf-8") == "restored" - else: - WorkspaceFixture(directories=("nested/deep",)).apply(tmp_path) - assert (tmp_path / "nested/deep").is_dir() - - -@pytest.mark.parametrize("path", ["", ".", "./"]) -def test_file_fixture_cannot_replace_its_root(path: str) -> None: - with pytest.raises(ValueError, match="root"): - FileFixture(path, "data") - - -@pytest.mark.parametrize("file_fixture", [False, True]) -def test_fixture_does_not_replace_a_file_used_as_its_root(tmp_path: Path, file_fixture: bool) -> None: - root = tmp_path / "root" - root.write_text("keep", encoding="utf-8") - with pytest.raises(OSError): - if file_fixture: - FileFixture("input.txt", "data").apply(root) - else: - WorkspaceFixture(directories=("nested",)).apply(root) - assert root.read_text(encoding="utf-8") == "keep" - - -def test_fixture_cleanup_removes_files_directories_and_symlinks(tmp_path: Path) -> None: - root = tmp_path / "root" - (root / "directory").mkdir(parents=True) - (root / "directory" / "file.txt").write_text("data", encoding="utf-8") - (root / "file.txt").write_text("data", encoding="utf-8") - (root / "link").symlink_to(root / "file.txt") - - WorkspaceFixture(clean=("directory", "file.txt", "link")).apply(root) - - assert not (root / "directory").exists() - assert not (root / "file.txt").exists() - assert not (root / "link").exists() - - -def test_fixture_cleanup_and_replace_recovers_from_chmod_000(tmp_path: Path) -> None: - root = tmp_path / "root" - root.mkdir() - restricted = root / "challenge" / "myproject" / "src" - restricted.mkdir(parents=True) - (restricted / "file.txt").write_text("code", encoding="utf-8") - (restricted / "file.txt").chmod(0) - restricted.chmod(0) - (root / "challenge" / "myproject").chmod(0) - - WorkspaceFixture(clean=("challenge",)).apply(root) - assert not (root / "challenge").exists() - - accidental_dir = root / "input.txt" - accidental_dir.mkdir() - (accidental_dir / "child").mkdir() - (accidental_dir / "child").chmod(0) - accidental_dir.chmod(0) - - FileFixture("input.txt", "restored").apply(root) - assert (root / "input.txt").is_file() - assert (root / "input.txt").read_text(encoding="utf-8") == "restored" - - -def test_level_prepare_recovers_from_corrupted_section_root(tmp_path: Path) -> None: - initialize_levels() - level = get_registry().get("3.2") - assert level is not None - - # Normal prepare - level.prepare(tmp_path) - secret = tmp_path / "level-3" / ".secret_config" - assert secret.is_file() - - # Case 1: section root made chmod 000 - (tmp_path / "level-3").chmod(0) - level.prepare(tmp_path) - assert secret.is_file() - - # Case 2: section root replaced by a file - shutil.rmtree(tmp_path / "level-3") - (tmp_path / "level-3").write_text("i am a file") - level.prepare(tmp_path) - assert secret.is_file() - - # Case 3: section root replaced by a symlink is rejected for safety - shutil.rmtree(tmp_path / "level-3") - (tmp_path / "level-3").symlink_to(tmp_path / "nonexistent") - with pytest.raises(ValueError, match="Section root"): - level.prepare(tmp_path) - - -@pytest.mark.parametrize( - ("field", "value"), - [ - ("directories", ("../outside",)), - ("clean", ("",)), - ], -) -def test_fixture_rejects_unsafe_paths(field: str, value: tuple[str, ...]) -> None: - with pytest.raises(ValueError): - WorkspaceFixture(**{field: value}) - - -def test_file_fixture_rejects_absolute_path() -> None: - with pytest.raises(ValueError): - FileFixture("/tmp/outside") - - -def test_file_fixture_rejects_symlink_escape(tmp_path: Path) -> None: - root = tmp_path / "root" - outside = tmp_path / "outside" - root.mkdir() - outside.mkdir() - (root / "link").symlink_to(outside, target_is_directory=True) - - with pytest.raises(ValueError): - FileFixture("link/file.txt", "data").apply(root) - - assert not (outside / "file.txt").exists() - - -@pytest.mark.parametrize("file_fixture", [False, True]) -def test_fixture_repair_does_not_replace_or_follow_leaf_symlinks(tmp_path: Path, file_fixture: bool) -> None: - root = tmp_path / "root" - root.mkdir() - outside = tmp_path / "outside" - outside.mkdir() - keep = outside / "keep.txt" - keep.write_text("keep", encoding="utf-8") - link = root / "link" - link.symlink_to(outside, target_is_directory=True) - - with pytest.raises(ValueError, match="Fixture"): - if file_fixture: - FileFixture("link", "replacement").apply(root) - else: - WorkspaceFixture(directories=("link",)).apply(root) - - assert link.is_symlink() - assert keep.read_text(encoding="utf-8") == "keep" - - -def test_workspace_fixture_rejects_symlinked_root(tmp_path: Path) -> None: - outside = tmp_path / "outside" - outside.mkdir() - root = tmp_path / "root" - root.symlink_to(outside, target_is_directory=True) - - with pytest.raises(ValueError, match="root cannot be a symlink"): - WorkspaceFixture(files=(FileFixture("file.txt", "data"),)).apply(root) - - assert not (outside / "file.txt").exists() - - -def test_fixture_cleanup_unlinks_external_symlink_without_following_it( - tmp_path: Path, -) -> None: - root = tmp_path / "root" - outside = tmp_path / "outside" - root.mkdir() - outside.mkdir() - target = outside / "keep.txt" - target.write_text("keep", encoding="utf-8") - (root / "link").symlink_to(target) - - WorkspaceFixture(clean=("link",)).apply(root) - - assert not (root / "link").exists() - assert target.read_text(encoding="utf-8") == "keep" - - -def test_completion_requirements_are_unconditional(tmp_path: Path) -> None: - completion = Completion( - answer=ExactAnswer("correct"), - requirements=(Evidence("required", "missing evidence"),), - allow_empty=True, - ) - state = _state(tmp_path) - - assert completion.validate("correct", state, root=tmp_path, success_message="done") == ( - False, - "missing evidence", - ) - assert completion.validate(None, state, root=tmp_path, success_message="done") == ( - False, - "missing evidence", - ) - - MarkerManager(tmp_path).create("required") - assert completion.validate(None, state, root=tmp_path, success_message="done") == (True, "done") - - -def test_completion_allows_empty_answer_only_when_requirement_matches( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - target = tmp_path / "target" - target.mkdir() - completion = Completion( - answer=ExactAnswer("target"), - allow_empty_when=AtDirectory("target"), - ) - state = _state(tmp_path) - monkeypatch.chdir(tmp_path) - - assert completion.validate("target", state, root=tmp_path, success_message="done") == (True, "done") - assert completion.validate(None, state, root=tmp_path, success_message="done")[0] is False - - monkeypatch.chdir(target) - assert completion.validate(None, state, root=tmp_path, success_message="done") == (True, "done") - - -def test_directory_feedback_is_non_revealing_and_keeps_custom_messages( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - root = tmp_path / "workspace" - target = root / "target" - target.mkdir(parents=True) - same_named_directory = tmp_path / "outside" / "target" - same_named_directory.mkdir(parents=True) - state = _state(root) - monkeypatch.chdir(same_named_directory) - - assert AtDirectory("target").check(state, root) == (False, Messages.WRONG_DIRECTORY) - assert AtDirectory("target", error_message="Follow the route.").check(state, root) == ( - False, - "Follow the route.", - ) - - monkeypatch.chdir(target) - assert AtDirectory("target").check(state, root)[0] - - -def test_tuple_answer_parses_parts_and_preserves_specific_feedback() -> None: - answer = TupleAnswer( - ( - ExactAnswer("alpha", mistakes={"alfa": "Použijte anglický zápis."}), - IntegerAnswer(3), - ) - ) - - assert answer.validate("alpha, 3")[0] is True - assert answer.validate("alpha")[0] is False - assert answer.validate("alfa, 3") == (False, "Použijte anglický zápis.") - assert answer.validate("alpha, three")[0] is False - - -def test_prepare_clears_declarative_evidence(tmp_path: Path) -> None: - class EvidenceLevel(Level): - title = "Evidence test" - completion = Completion(requirements=(Evidence("temporary"),)) - - markers = MarkerManager(tmp_path) - markers.create("temporary") - - EvidenceLevel().prepare(tmp_path) - - assert not markers.exists("temporary") - - -def test_regular_level_requires_completion_or_custom_validation() -> None: - class IncompleteLevel(Level): - title = "Incomplete" - - with pytest.raises(ValueError, match="require Completion"): - IncompleteLevel() - - -def test_level_prepare_rejects_symlinked_section_root(tmp_path: Path) -> None: - class EscapingLevel(Level): - title = "Escaping" - completion = Completion() - section_root = "section" - fixture = WorkspaceFixture(files=(FileFixture("created.txt", "data"),)) - - workspace = tmp_path / "workspace" - outside = tmp_path / "outside" - workspace.mkdir() - outside.mkdir() - (workspace / "section").symlink_to(outside, target_is_directory=True) - - with pytest.raises(ValueError, match="Section root"): - EscapingLevel().prepare(workspace) - - assert not (outside / "created.txt").exists() - - -def test_section_assigns_local_stable_ids_and_rejects_duplicates() -> None: - section = Section(12) - - @section.level(3) - class RegisteredLevel(Level): - title = "Registered" - completion = Completion() - - assert RegisteredLevel.id == "12.3" - assert RegisteredLevel.section == 12 - assert section.levels[0].id == "12.3" - - with pytest.raises(ValueError, match="Duplicate level ID"): - - @section.level(3) - class DuplicateLevel(Level): - title = "Duplicate" - completion = Completion() - - -def test_section_rejects_unsafe_root() -> None: - with pytest.raises(ValueError, match="workspace-relative"): - Section(1, root="../outside") - - -def test_common_answer_rules() -> None: - assert ChoiceAnswer(("yes", "ano"), case_sensitive=False).validate("ANO")[0] is True - assert IntegerRangeAnswer(1, 5, "range").validate("3")[0] is True - assert IntegerRangeAnswer(1, 5, "range").validate("6") == (False, "range") - assert IntegerRangeAnswer(1, 5).validate("6") == (False, Messages.INCORRECT) - assert OrderedListAnswer(("a", "b")).validate("a, b")[0] is True - assert SuffixAnswer("path/file.txt").validate("/tmp/path/file.txt")[0] is True - assert SuffixAnswer("path/file.txt").validate("wrong-path/file.txt")[0] is False - - -@pytest.mark.parametrize( - "factory", - [ - lambda: AtDirectory("../outside"), - lambda: FileExists("/tmp/outside"), - lambda: TextFileContent("../outside"), - lambda: PathsMatch("source", "../outside"), - ], -) -def test_completion_rules_reject_unsafe_paths(factory: Callable[[], object]) -> None: - with pytest.raises(ValueError): - factory() - - -def test_filesystem_requirements(tmp_path: Path) -> None: - state = _state(tmp_path) - source = tmp_path / "source" - destination = tmp_path / "destination" - source.mkdir() - destination.mkdir() - (source / "file.txt").write_text("first\nsecond\n", encoding="utf-8") - (destination / "file.txt").write_text("first\nsecond\n", encoding="utf-8") - moved = tmp_path / "moved.txt" - moved.write_text("moved", encoding="utf-8") - - assert DirectoryExists("source").check(state, tmp_path)[0] is True - assert FileExists("source/file.txt").check(state, tmp_path)[0] is True - assert TextFileContent("source/file.txt", contains=("first", "second")).check(state, tmp_path)[0] is True - assert FileLineCount("source/file.txt", 2, "wrong lines").check(state, tmp_path)[0] is True - assert PathsMatch("source", "destination").check(state, tmp_path)[0] is True - assert PathMoved("old.txt", "moved.txt").check(state, tmp_path)[0] is True - - (destination / "extra.txt").write_text("extra", encoding="utf-8") - assert PathsMatch("source", "destination").check(state, tmp_path)[0] is False - - -def test_filesystem_requirements_should_not_exist(tmp_path: Path) -> None: - state = _state(tmp_path) - - # Completely absent path passes should_exist=False - assert FileExists("absent.txt", should_exist=False).check(state, tmp_path)[0] is True - assert DirectoryExists("absent_dir", should_exist=False).check(state, tmp_path)[0] is True - - # Regular file exists: both should fail should_exist=False - (tmp_path / "actual_file.txt").write_text("data", encoding="utf-8") - assert FileExists("actual_file.txt", should_exist=False).check(state, tmp_path)[0] is False - assert DirectoryExists("actual_file.txt", should_exist=False).check(state, tmp_path)[0] is False - - # Directory exists: both should fail should_exist=False - (tmp_path / "actual_dir").mkdir() - assert FileExists("actual_dir", should_exist=False).check(state, tmp_path)[0] is False - assert DirectoryExists("actual_dir", should_exist=False).check(state, tmp_path)[0] is False - - -def test_completion_rejects_symlinked_requirement_paths( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - outside = tmp_path / "outside" - outside.mkdir() - (outside / "file.txt").write_text("data", encoding="utf-8") - (tmp_path / "link").symlink_to(outside, target_is_directory=True) - state = _state(tmp_path) - completion = Completion( - requirements=( - FileExists("link/file.txt"), - AtDirectory("link"), - ) - ) - monkeypatch.chdir(outside) - - success, message = completion.validate(None, state, root=tmp_path, success_message="done") - - assert success is False - assert "pracovní prostor" in message - - -def test_permission_requirements(tmp_path: Path) -> None: - target = tmp_path / "script.sh" - target.write_text("#!/bin/sh\n", encoding="utf-8") - target.chmod(0o755) - state = _state(tmp_path) - - assert PermissionMode("script.sh", 0o755).check(state, tmp_path)[0] is True - wrong_mode, wrong_message = PermissionMode("script.sh", 0o644).check(state, tmp_path) - assert wrong_mode is False - assert "644" not in wrong_message - assert "755" not in wrong_message - assert PermissionBits("script.sh", required=stat.S_IXUSR).check(state, tmp_path)[0] is True - assert PermissionBits("script.sh", forbidden=stat.S_IWOTH).check(state, tmp_path)[0] is True - - -def test_fixture_overwrites_a_file_the_player_made_read_only(tmp_path: Path) -> None: - """Reset must restore a level even after the player revoked write permission. - - Section 7 teaches `chmod`, so leaving a fixture file read-only is a correct - solution, not misuse. Before this was handled, re-applying the fixture raised - `PermissionError` and `shellgame reset` could never hand the level back. - """ - fixture = WorkspaceFixture(files=(FileFixture("permissions/config.readonly", content="Do not touch", mode=0o644),)) - fixture.apply(tmp_path) - - target = tmp_path / "permissions" / "config.readonly" - target.write_text("player edit", encoding="utf-8") - target.chmod(0o444) - - fixture.apply(tmp_path) - - assert target.read_text(encoding="utf-8") == "Do not touch" - assert target.stat().st_mode & 0o777 == 0o644 diff --git a/tests/test_directory_permissions.py b/tests/test_directory_permissions.py deleted file mode 100644 index 6cbebbe..0000000 --- a/tests/test_directory_permissions.py +++ /dev/null @@ -1,198 +0,0 @@ -"""Focused tests for declarative directory fixtures and permission requirements.""" - -from __future__ import annotations - -import shutil -import stat -from dataclasses import dataclass -from pathlib import Path - -import pytest - -from shellgame.levels.base import Level -from shellgame.levels.completion import Completion, DirectoryPermissionMode, PermissionMode -from shellgame.levels.fixture import DirectoryFixture, FileFixture, WorkspaceFixture -from shellgame.messages import Messages - - -@dataclass -class _State: - workspace: Path - username: str = "tester" - current_level: str = "test" - - -class _DirectoryFixtureLevel(Level): - title = "Directory fixture test" - section_root = "section" - fixture = WorkspaceFixture( - directory_fixtures=(DirectoryFixture("project/private", mode=0o500),), - files=(FileFixture("project/private/config.txt", content="restored"),), - ) - completion = Completion() - - -def _mode(path: Path) -> int: - return stat.S_IMODE(path.stat().st_mode) - - -def _remove_fixture_tree(path: Path, target: Path) -> None: - target.chmod(0o700) - target.parent.chmod(0o700) - if path != target: - path.chmod(0o700) - shutil.rmtree(path) - - -def test_workspace_fixture_applies_exact_directory_modes_after_child_files(tmp_path: Path) -> None: - fixture = WorkspaceFixture( - directory_fixtures=( - DirectoryFixture("vault", mode=0o000), - DirectoryFixture("vault/records", mode=0o750), - ), - files=(FileFixture("vault/records/report.txt", content="data"),), - ) - - fixture.apply(tmp_path) - - vault = tmp_path / "vault" - assert _mode(vault) == 0o000 - vault.chmod(0o100) - assert _mode(vault / "records") == 0o750 - assert (vault / "records/report.txt").read_text(encoding="utf-8") == "data" - vault.chmod(0o700) - - -@pytest.mark.parametrize( - "corruption", - ["chmod-000", "target-file", "parent-file", "target-symlink", "parent-symlink"], -) -def test_level_reset_repairs_directory_fixture_without_escaping_root(tmp_path: Path, corruption: str) -> None: - workspace = tmp_path / "workspace" - workspace.mkdir() - level = _DirectoryFixtureLevel() - level.prepare(workspace) - - parent = workspace / "section/project" - target = parent / "private" - outside = tmp_path / "outside" - outside.mkdir() - keep = outside / "keep.txt" - keep.write_text("keep", encoding="utf-8") - - if corruption == "chmod-000": - target.chmod(0) - parent.chmod(0) - elif corruption == "target-file": - _remove_fixture_tree(target, target) - target.write_text("wrong type", encoding="utf-8") - elif corruption == "parent-file": - _remove_fixture_tree(parent, target) - parent.write_text("wrong type", encoding="utf-8") - elif corruption == "target-symlink": - _remove_fixture_tree(target, target) - target.symlink_to(outside, target_is_directory=True) - else: - _remove_fixture_tree(parent, target) - parent.symlink_to(outside, target_is_directory=True) - - level.reset(workspace) - - assert target.is_dir() - assert not target.is_symlink() - assert _mode(target) == 0o500 - assert (target / "config.txt").read_text(encoding="utf-8") == "restored" - assert keep.read_text(encoding="utf-8") == "keep" - assert not (outside / "private").exists() - assert not (outside / "config.txt").exists() - - -def test_directory_fixture_rejects_unsafe_paths_and_modes() -> None: - with pytest.raises(ValueError, match="cannot target its root"): - DirectoryFixture("", mode=0o755) - - for path in ("../outside", "/tmp/outside"): - with pytest.raises(ValueError, match="relative"): - DirectoryFixture(path, mode=0o755) - - for mode in (-1, 0o10000): - with pytest.raises(ValueError, match="between"): - DirectoryFixture("private", mode=mode) - - -def test_directory_fixture_rejects_a_symlinked_root(tmp_path: Path) -> None: - outside = tmp_path / "outside" - outside.mkdir() - root = tmp_path / "root" - root.symlink_to(outside, target_is_directory=True) - - with pytest.raises(ValueError, match="root cannot be a symlink"): - DirectoryFixture("private", mode=0o700).apply(root) - - assert not (outside / "private").exists() - - -def test_directory_permission_mode_accepts_only_the_exact_directory_mode(tmp_path: Path) -> None: - target = tmp_path / "private" - target.mkdir() - target.chmod(0o750) - state = _State(tmp_path) - - assert DirectoryPermissionMode("private", 0o750).check(state, tmp_path) == ( - True, - Messages.PERMISSION_CORRECT, - ) - - passed, message = DirectoryPermissionMode("private", 0o700).check(state, tmp_path) - assert passed is False - assert message == Messages.PERMISSION_WRONG - assert "700" not in message - assert "750" not in message - - assert DirectoryPermissionMode("private", 0o700, error_message="Upravte oprávnění.").check(state, tmp_path) == ( - False, - "Upravte oprávnění.", - ) - - -@pytest.mark.parametrize("entry_type", ["missing", "file"]) -def test_directory_permission_mode_rejects_missing_or_non_directory_paths(tmp_path: Path, entry_type: str) -> None: - target = tmp_path / "private" - if entry_type == "file": - target.write_text("not a directory", encoding="utf-8") - - assert DirectoryPermissionMode("private", 0o700).check(_State(tmp_path), tmp_path) == ( - False, - Messages.DIR_NOT_EXISTS.format(path="private"), - ) - - -def test_directory_permission_mode_validates_path_and_mode_declarations() -> None: - for path in ("../outside", "/tmp/outside"): - with pytest.raises(ValueError, match="relative"): - DirectoryPermissionMode(path, 0o700) - - for mode in (-1, 0o10000): - with pytest.raises(ValueError, match="between"): - DirectoryPermissionMode("private", mode) - - -def test_directory_permission_mode_does_not_follow_symlinks(tmp_path: Path) -> None: - root = tmp_path / "root" - outside = tmp_path / "outside" - root.mkdir() - outside.mkdir() - outside.chmod(0o700) - (root / "private").symlink_to(outside, target_is_directory=True) - completion = Completion(requirements=(DirectoryPermissionMode("private", 0o700),)) - - assert completion.validate(None, _State(root), root=root, success_message="done") == ( - False, - Messages.PATH_ESCAPES_WORKSPACE, - ) - - -def test_directory_permission_mode_participates_in_permission_mode_invariants() -> None: - requirement = DirectoryPermissionMode("private", 0o700) - - assert isinstance(requirement, PermissionMode) diff --git a/tests/test_discovery_commands.py b/tests/test_discovery_commands.py deleted file mode 100644 index 52678d3..0000000 --- a/tests/test_discovery_commands.py +++ /dev/null @@ -1,238 +0,0 @@ -"""Grade answers obtained from the commands actually taught by discovery levels.""" - -import os -import shutil -import subprocess -from datetime import datetime -from pathlib import Path - -import pytest - -from shellgame.levels.base import Level -from shellgame.levels.sections.section1 import LsLevel -from shellgame.levels.sections.section2 import HelpDiscoveryLevel -from shellgame.levels.sections.section3 import HiddenDirCountLevel, HiddenFilesSummaryChallengeLevel -from shellgame.levels.sections.section5 import FileDetectiveChallengeLevel -from shellgame.levels.sections.section8 import MakeExecutableLevel, MakeReadOnlyLevel, PermissionsChallengeLevel -from shellgame.levels.sections.section9 import WordAndLineCountLevel -from shellgame.levels.sections.section11 import GrepConfigLineToFileLevel, RecursiveGrepFindFileLevel -from shellgame.levels.solution import RunShell -from shellgame.state.manager import GameState - - -@pytest.fixture -def command_env(tmp_path: Path) -> dict[str, str]: - home = tmp_path / "home" - home.mkdir() - scratch = tmp_path / "shell-scratch" - scratch.mkdir() - env = { - key: value - for key, value in os.environ.items() - if not key.startswith(("SHELLGAME_", "BASH_FUNC_")) - and key not in {"BASH_ENV", "ENV", "BASHOPTS", "SHELLOPTS", "CDPATH", "OLDPWD"} - } - env.update( - HOME=str(home), - XDG_CONFIG_HOME=str(home / "config"), - XDG_DATA_HOME=str(home / "data"), - XDG_CACHE_HOME=str(home / "cache"), - TMPDIR=str(scratch), - LC_ALL="C", - ) - return env - - -def _run(args: list[str], start: Path, env: dict[str, str]) -> str: - result = subprocess.run(args, cwd=start, env=env, capture_output=True, text=True, check=True, timeout=10) - assert not result.stderr - return result.stdout - - -def _state(workspace: Path, level: Level) -> GameState: - return GameState(username="tester", workspace=workspace, current_level=level.id, start_time=datetime.now()) - - -@pytest.mark.skipif(shutil.which("file") is None, reason="file not installed") -def test_file_detective_answer_can_be_derived_from_file_output( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, command_env: dict[str, str] -) -> None: - level = FileDetectiveChallengeLevel() - workspace = tmp_path / "workspace" - workspace.mkdir() - state = _state(workspace, level) - assert "bez skriptů" in level.instructions - - for prepare in (level.prepare, level.reset): - prepare(workspace) - start = level.get_start_directory(workspace) - assert start is not None - monkeypatch.chdir(start) - names = sorted(path.name for path in start.iterdir()) - descriptions = _run(["file", "-b", "--", *names], start, command_env).splitlines() - assert len(names) == len(descriptions) == 5 - plain = sum(description.startswith("ASCII text") for description in descriptions) - images = sum("image" in description for description in descriptions) - scripts = [ - name for name, description in zip(names, descriptions, strict=True) if "Python script" in description - ] - assert len(scripts) == 1 - assert images == 1 - assert sum("ASCII text" in description for description in descriptions) > plain - success, message = level.validate(f"{plain},{images},{scripts[0]}", state) - assert success, message - - (start / "config.txt").write_bytes(b"not an image") - (start / "extra.txt").write_text("extra", encoding="utf-8") - - -@pytest.mark.parametrize( - "level_type", [HiddenDirCountLevel, HiddenFilesSummaryChallengeLevel], ids=lambda level: level.id -) -def test_hidden_counts_are_distinguishable_and_resettable( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, command_env: dict[str, str], level_type: type[Level] -) -> None: - level = level_type() - workspace = tmp_path / "workspace" - workspace.mkdir() - state = _state(workspace, level) - assert "`ls -la`" in level.instructions - - for prepare in (level.prepare, level.reset): - prepare(workspace) - start = level.get_start_directory(workspace) - assert start is not None - monkeypatch.chdir(start) - listing = _run(["ls", "-la"], start, command_env).splitlines() - entries = [parts for line in listing if len(parts := line.split(maxsplit=8)) == 9] - hidden = [parts for parts in entries if parts[8].startswith(".") and parts[8] not in {".", ".."}] - directories = sum(parts[0].startswith("d") for parts in hidden) - files = len(hidden) - directories - suffix = "" - if isinstance(level, HiddenFilesSummaryChallengeLevel): - suffix = "," + _run(["cat", ".secret_code"], start, command_env).strip() - assert files != directories - assert level.validate(f"{directories}{suffix}", state)[0] - assert not level.validate(f"{files}{suffix}", state)[0] - assert not level.validate(f"{len(hidden)}{suffix}", state)[0] - - (start / ".extra-directory").mkdir() - (start / ".extra-file").write_text("extra", encoding="utf-8") - - -@pytest.mark.parametrize("level_type", [MakeExecutableLevel, MakeReadOnlyLevel, PermissionsChallengeLevel]) -@pytest.mark.parametrize("mask", ["000", "022", "077", "777"]) -@pytest.mark.parametrize("shell", ["bash", "fish"]) -def test_taught_permission_commands_are_independent_of_umask( - tmp_path: Path, - command_env: dict[str, str], - level_type: type[Level], - mask: str, - shell: str, -) -> None: - if not shutil.which(shell): - pytest.skip(f"{shell} not installed") - level = level_type() - workspace = tmp_path / "workspace" - workspace.mkdir() - level.prepare(workspace) - state = _state(workspace, level) - start = level.get_start_directory(workspace) - assert start is not None - assert level.solution is not None - assert not level.validate(level.solution.answer, state)[0] - commands = [step.command for step in level.solution.steps if isinstance(step, RunShell)] - assert commands - for recipe in commands: - for command in recipe.split(" && "): - assert command in " ".join(level.hints) - flags = ["--no-config"] if shell == "fish" else [] - _run([shell, *flags, "-c", f"umask {mask}; " + " && ".join(commands)], start, command_env) - success, message = level.validate(level.solution.answer, state) - assert success, message - - -@pytest.mark.skipif(shutil.which("wc") is None, reason="wc not installed") -def test_word_count_answer_matches_wc_output(tmp_path: Path, command_env: dict[str, str]) -> None: - level = WordAndLineCountLevel() - workspace = tmp_path / "workspace" - workspace.mkdir() - state = _state(workspace, level) - - for prepare in (level.prepare, level.reset): - prepare(workspace) - start = level.get_start_directory(workspace) - assert start is not None - lines = _run(["wc", "-l", "article.txt"], start, command_env).split()[0] - words = _run(["wc", "-w", "article.txt"], start, command_env).split()[0] - success, message = level.validate(f"{lines},{words}", state) - assert success, message - - (start / "article.txt").write_text("changed\n", encoding="utf-8") - - -@pytest.mark.skipif(shutil.which("grep") is None, reason="grep not installed") -def test_recursive_grep_accepts_paths_printed_from_either_directory( - tmp_path: Path, command_env: dict[str, str] -) -> None: - level = RecursiveGrepFindFileLevel() - workspace = tmp_path / "workspace" - workspace.mkdir() - level.prepare(workspace) - state = _state(workspace, level) - start = level.get_start_directory(workspace) - assert start is not None - - from_start = _run(["grep", "-r", "SECRET_KEY", "project/"], start, command_env).split(":", maxsplit=1)[0] - from_project = _run(["grep", "-r", "SECRET_KEY", "."], start / "project", command_env).split(":", maxsplit=1)[0] - - assert level.validate(from_start, state)[0] - assert level.validate(from_project, state)[0] - - -def test_grep_hints_do_not_reveal_the_matching_line(tmp_path: Path) -> None: - level = GrepConfigLineToFileLevel() - workspace = tmp_path / "workspace" - workspace.mkdir() - level.prepare(workspace) - start = level.get_start_directory(workspace) - assert start is not None - matching_line = next( - line for line in (start / "config.txt").read_text(encoding="utf-8").splitlines() if "ACTIVE_PROFILE" in line - ) - - assert all(matching_line not in hint for hint in level.hints) - - -def test_level1_2_accepts_trailing_slash_from_tab_completion(tmp_path: Path) -> None: - level = LsLevel() - workspace = tmp_path / "workspace" - workspace.mkdir() - state = _state(workspace, level) - - # delta/ from tab-completion must succeed - ok, msg = level.validate("delta/", state) - assert ok is True - - # data/ must trigger the specific file mistake, not a pattern mismatch - ok, msg = level.validate("data/", state) - assert ok is False - assert "není to adresář" in msg - - # dog/ must trigger the pattern mismatch without ugly trailing slash in the diagnostic - ok, msg = level.validate("dog/", state) - assert ok is False - assert "'dog' začíná na 'd'" in msg - - -def test_level2_8_hints_do_not_reveal_the_answer() -> None: - level = HelpDiscoveryLevel() - for hint in level.hints: - assert "odpověď je 'human'" not in hint.lower() - assert "odpoved je 'human'" not in hint.lower() - - -def test_bash_template_enforces_globasciiranges() -> None: - template_path = Path("src/shellgame/cli/templates/bash_integration.template") - content = template_path.read_text(encoding="utf-8") - assert "shopt -s globasciiranges" in content diff --git a/tests/test_fish_cd_wrapper_regression.py b/tests/test_fish_cd_wrapper_regression.py deleted file mode 100644 index 592c533..0000000 --- a/tests/test_fish_cd_wrapper_regression.py +++ /dev/null @@ -1,10 +0,0 @@ -from __future__ import annotations - -from shellgame.cli.subshell import get_fish_integration - - -def test_generated_fish_integration_uses_builtin_cd_fallback() -> None: - script = get_fish_integration("shellgame") - # In fish, `cd` is a builtin; `command cd` fails with "Unknown command: cd". - assert "builtin cd $argv" in script - assert "command cd $argv" not in script diff --git a/tests/test_hint_quality.py b/tests/test_hint_quality.py deleted file mode 100644 index d4af2a7..0000000 --- a/tests/test_hint_quality.py +++ /dev/null @@ -1,269 +0,0 @@ -"""Pedagogical guarantees about hints and assessment copy. - -A hint ladder should scaffold, not hand over the answer. Assessment levels -(section summaries and challenges) are where students demonstrate what they -learned, so a hint there must never contain the literal expected answer - that -turns the assessment into a lookup. - -Levels whose *instructions* already state the answer (for example "submit the -password: builder") are exempt from the hint check: repeating it reveals -nothing. ``error_message`` and discovery ``shellgame submit …`` examples are -never exempt — those surfaces must not print the value. -""" - -from __future__ import annotations - -import re -from datetime import datetime -from pathlib import Path - -import pytest - -from shellgame.levels.base import Level -from shellgame.levels.completion import ( - AtDirectory, - ExactAnswer, - IntegerAnswer, - SuffixAnswer, - TupleAnswer, -) -from shellgame.levels.loader import initialize_levels -from shellgame.levels.registry import get_registry -from shellgame.messages import Messages -from shellgame.state.manager import GameState - -#: Answers shorter than this match too many unrelated words to be meaningful. -_MIN_ANSWER_LENGTH = 4 - -_ASSESSMENT_MARKERS = ("souhrn", "výzva", "challenge", "finální") - - -@pytest.fixture(scope="module") -def levels() -> list: - initialize_levels() - return list(get_registry().list_levels()) - - -def _expected_answers(rule: object) -> list[str]: - if isinstance(rule, TupleAnswer): - parts = [value for part in rule.parts for value in _expected_answers(part)] - raw = [str(part.expected) for part in rule.parts if isinstance(part, (ExactAnswer, IntegerAnswer))] - if len(raw) == len(rule.parts) and raw: - parts.append(rule.separator.join(raw)) - return parts - if isinstance(rule, SuffixAnswer): - return [rule.expected] - if isinstance(rule, (ExactAnswer, IntegerAnswer)): - return [str(rule.expected)] - return [] - - -def _error_messages(rule: object) -> list[str]: - if isinstance(rule, TupleAnswer): - return [message for part in rule.parts for message in _error_messages(part)] - message = getattr(rule, "error_message", None) - messages = [message] if message else [] - if isinstance(rule, (ExactAnswer, IntegerAnswer)): - messages.extend(rule.mistakes.values()) - return messages - - -def _is_assessment(level: object) -> bool: - title = str(getattr(level, "title", "")).lower() - return any(marker in title for marker in _ASSESSMENT_MARKERS) - - -def test_assessment_hints_do_not_contain_the_answer(levels: list) -> None: - leaks: list[str] = [] - - for level in levels: - if not _is_assessment(level): - continue - - completion = getattr(level, "completion", None) - if completion is None or completion.answer is None: - continue - - instructions = str(getattr(level, "instructions", "")).lower() - answers = [ - answer - for answer in _expected_answers(completion.answer) - if len(answer) >= _MIN_ANSWER_LENGTH or ("," in answer and len(answer) >= 3) - ] - - for index, hint in enumerate(getattr(level, "hints", []) or [], start=1): - hint_text = str(hint).lower() - for answer in answers: - if "," not in answer and answer.lower() in instructions: - continue # the task itself states this value - if answer.lower() in hint_text: - leaks.append(f"{level.id} ({level.title}) hint #{index} reveals {answer!r}: {hint}") - - assert not leaks, "Assessment hints must scaffold, not reveal:\n" + "\n".join(leaks) - - -def test_error_message_never_contains_the_expected_answer(levels: list) -> None: - """A wrong submit must not print the value the player was looking for.""" - leaks: list[str] = [] - - for level in levels: - completion = getattr(level, "completion", None) - if completion is None or completion.answer is None: - continue - - answers = [answer for answer in _expected_answers(completion.answer) if len(answer) >= _MIN_ANSWER_LENGTH] - for message in _error_messages(completion.answer): - lowered = message.lower() - for answer in answers: - if answer.lower() in lowered: - leaks.append(f"{level.id} ({level.title}) feedback reveals {answer!r}: {message}") - - assert not leaks, "Feedback must not contain the expected answer:\n" + "\n".join(leaks) - - -@pytest.mark.parametrize("level_id", ["1.1", "2.1", "2.4", "9.8"]) -def test_discovery_final_hint_does_not_give_the_answer(levels: list[Level], level_id: str) -> None: - level = next(level for level in levels if level.id == level_id) - assert level.completion is not None - assert isinstance(level.completion.answer, (ExactAnswer, IntegerAnswer)) - expected = str(level.completion.answer.expected) - - assert not re.search(rf"(?<!\w){re.escape(expected)}(?!\w)", level.hints[-1], re.IGNORECASE) - - -def test_navigation_feedback_does_not_reveal_target( - levels: list[Level], tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - workspace = tmp_path / "workspace" - workspace.mkdir() - monkeypatch.chdir(tmp_path) - state = GameState(username="tester", workspace=workspace, current_level="test", start_time=datetime.now()) - - for level in levels: - completion = level.completion - if completion is None: - continue - requirements = [*completion.requirements, completion.allow_empty_when] - directories = [requirement for requirement in requirements if isinstance(requirement, AtDirectory)] - if not directories: - continue - level.prepare(workspace) - root = level.section_path(workspace) - for requirement in directories: - expected = (root / requirement.path).name - success, message = requirement.check(state, root) - assert not success, level.id - assert not re.search(rf"(?<!\w){re.escape(expected)}(?!\w)", message, re.IGNORECASE), ( - f"{level.id}: {message}" - ) - - -@pytest.mark.parametrize("answer", ["inside.txt", "other.csv"]) -def test_extension_feedback_preserves_location_guard_and_does_not_echo_answer( - levels: list[Level], tmp_path: Path, monkeypatch: pytest.MonkeyPatch, answer: str -) -> None: - level = next(level for level in levels if level.id == "1.3") - level.prepare(tmp_path) - state = GameState(username="tester", workspace=tmp_path, current_level=level.id, start_time=datetime.now()) - root = level.section_path(tmp_path) - monkeypatch.chdir(root) - assert level.validate(answer, state) == (False, Messages.WRONG_DIRECTORY) - - monkeypatch.chdir(root / "alpha") - assert level.validate(answer, state) == (False, Messages.L1_3_INCLUDED_EXTENSION) - assert "inside" not in Messages.L1_3_INCLUDED_EXTENSION - assert level.validate("inside", state)[0] - - -def test_discovery_submit_line_does_not_include_the_path(levels: list) -> None: - """Finding a path is the task; the submit example must stay a placeholder.""" - leaks: list[str] = [] - - for level in levels: - completion = getattr(level, "completion", None) - if completion is None or not isinstance(completion.answer, SuffixAnswer): - continue - - needle = f"shellgame submit {completion.answer.expected}" - if needle.lower() in str(getattr(level, "instructions", "")).lower(): - leaks.append(f"{level.id} ({level.title}) instructions contain {needle!r}") - - assert not leaks, "Discovery submit examples must use a placeholder:\n" + "\n".join(leaks) - - -@pytest.mark.parametrize("level_id", ["5.1", "8.1", "9.6"]) -def test_discovery_submit_examples_do_not_reveal_values(levels: list[Level], level_id: str) -> None: - level = next(level for level in levels if level.id == level_id) - assert level.completion is not None - examples = re.findall(r"`shellgame submit ([^`]+)`", level.instructions) - assert examples - for example in examples: - assert "<" in example and ">" in example - for expected in _expected_answers(level.completion.answer): - assert expected.lower() not in example.lower() - - -def test_intro_hints_continue_with_enter(levels: list) -> None: - """Intros auto-advance on Enter. Telling the player to submit contradicts the engine.""" - broken: list[str] = [] - - for level in levels: - if not getattr(level, "is_intro", False): - continue - blob = " ".join(getattr(level, "hints", []) or []).lower() - if "enter" not in blob or "submit" in blob: - broken.append(f"{level.id}: {blob!r}") - - assert not broken, "Intro hints must say Enter, not submit:\n" + "\n".join(broken) - - -def test_every_level_has_usable_hints(levels: list) -> None: - """Empty or whitespace-only hints would burn a hint with no help.""" - broken = [ - f"{level.id} hint #{index}" - for level in levels - for index, hint in enumerate(getattr(level, "hints", []) or [], start=1) - if not str(hint).strip() - ] - assert not broken, f"Empty hints: {broken}" - - -def test_hint_progression_scaffolding(levels: list[Level]) -> None: - """Verify that multi-step hints in key operational levels introduce concept first.""" - checked_levels = { - "2.1", - "2.2", - "2.3", - "2.4", - "2.6", - "4.2", - "4.7", - "6.2", - "6.3", - "6.4", - "6.5", - "6.6", - "7.1", - "7.5", - "8.1", - "8.3", - "8.4", - "9.2", - "9.3", - "9.4", - "9.9", - "10.5", - "11.1", - } - # A listed ID that no longer exists would silently stop being checked, so the - # set is verified against the registry before it is used. - registered = {level.id for level in levels} - assert checked_levels <= registered, f"Unknown level IDs listed: {sorted(checked_levels - registered)}" - - for level in levels: - if level.id in checked_levels: - assert len(level.hints) >= 2, f"{level.id} should have at least 2 hints" - # Hint 1 should explain concept/syntax, not start with 'Spusťte' or 'Použijte' - hint1 = level.hints[0] - assert not hint1.startswith("Použijte '"), f"{level.id} hint 1 gives command immediately: {hint1}" - assert not hint1.startswith("Spusťte '"), f"{level.id} hint 1 gives command immediately: {hint1}" diff --git a/tests/test_level_invariants.py b/tests/test_level_invariants.py deleted file mode 100644 index 988e787..0000000 --- a/tests/test_level_invariants.py +++ /dev/null @@ -1,225 +0,0 @@ -"""Cross-section invariants for declarative level configuration.""" - -import stat -from datetime import datetime -from pathlib import Path -from typing import Any - -import pytest - -from shellgame.levels.base import Level -from shellgame.levels.completion import AtDirectory -from shellgame.levels.loader import initialize_levels -from shellgame.levels.registry import get_registry -from shellgame.markers import MarkerManager -from shellgame.messages import Messages -from shellgame.state.manager import GameState - - -def _all_levels() -> list[Level]: - initialize_levels() - return get_registry().list_levels() - - -@pytest.mark.parametrize("level", _all_levels(), ids=lambda level: level.id) -def test_level_setup_creates_declared_start_directory(level: Level, tmp_path: Path) -> None: - workspace = tmp_path / str(level.id).replace(".", "_") - workspace.mkdir() - - level.prepare(workspace) - - start_directory = level.get_start_directory(workspace) - if start_directory is not None: - assert start_directory.is_dir() - - -@pytest.mark.parametrize( - "level", - [ - level - for level in _all_levels() - if any(fixture and fixture.files for fixture in (level.section_fixture, level.fixture)) - ], - ids=lambda level: level.id, -) -def test_reset_repairs_declared_files_replaced_by_directories(level: Level, tmp_path: Path) -> None: - workspace = tmp_path / "workspace" - workspace.mkdir() - level.prepare(workspace) - root = level.section_path(workspace) - paths = { - root / file.path - for fixture in (level.section_fixture, level.fixture) - if fixture is not None - for file in fixture.files - } - for target in paths: - assert target.is_file() - target.parent.chmod(stat.S_IRWXU) - target.unlink() - target.mkdir() - (target / "mistake.txt").write_text("wrong type", encoding="utf-8") - - level.reset(workspace) - - assert all(target.is_file() for target in paths) - - -def test_generated_log_reset_repairs_directory_in_place_of_file(tmp_path: Path) -> None: - level = next(level for level in _all_levels() if level.id == "5.4") - level.prepare(tmp_path) - log = level.section_path(tmp_path) / "logs" / "server.log" - expected = log.read_bytes() - log.unlink() - log.mkdir() - (log / "mistake.txt").write_text("wrong type", encoding="utf-8") - - level.reset(tmp_path) - - assert log.is_file() - assert log.read_bytes() == expected - - -@pytest.mark.parametrize("level", _all_levels(), ids=lambda level: level.id) -def test_completion_directories_are_created(level: Level, tmp_path: Path) -> None: - if level.completion is None: - return - - requirements = [*level.completion.requirements] - if level.completion.allow_empty_when is not None: - requirements.append(level.completion.allow_empty_when) - directory_requirements = [requirement for requirement in requirements if isinstance(requirement, AtDirectory)] - if not directory_requirements: - return - - workspace = tmp_path / str(level.id).replace(".", "_") - workspace.mkdir() - level.prepare(workspace) - root = level.section_path(workspace) - assert all((root / requirement.path).is_dir() for requirement in directory_requirements) - - -@pytest.mark.parametrize("level", _all_levels(), ids=lambda level: level.id) -def test_fresh_regular_level_rejects_empty_submit( - level: Level, tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - if level.is_intro: - return - - workspace = tmp_path / str(level.id).replace(".", "_") - workspace.mkdir() - level.prepare(workspace) - monkeypatch.chdir(workspace) - state = GameState( - username="tester", - workspace=workspace, - current_level=str(level.id), - start_time=datetime.now(), - ) - - success, _ = level.validate(None, state) - - assert success is False - - -@pytest.mark.parametrize( - "level", - [ - level - for level in _all_levels() - if level.reset_markers or (level.completion and level.completion.evidence_markers) - ], - ids=lambda level: level.id, -) -def test_level_prepare_clears_all_command_evidence(level: Level, tmp_path: Path) -> None: - workspace = tmp_path / str(level.id).replace(".", "_") - workspace.mkdir() - markers = MarkerManager(workspace) - completion_markers = level.completion.evidence_markers if level.completion else () - marker_names = [name for name in (*level.reset_markers, *completion_markers) if name] - for marker_name in marker_names: - markers.create(marker_name) - - level.prepare(workspace) - - assert all(not markers.exists(marker_name) for marker_name in marker_names) - - -@pytest.mark.parametrize("level", _all_levels(), ids=lambda level: level.id) -def test_level_instructions_use_supported_submit_syntax(level: Level) -> None: - assert "shellgame submit -f" not in level.instructions - - -def test_registered_level_ids_are_unique() -> None: - levels = _all_levels() - assert len({level.id for level in levels}) == len(levels) - - -#: Levels deliberately marked as bonus content, with the reason they qualify. -#: Extra repetition of a skill the preceding level already taught, so a confident -#: player may `shellgame skip` them. Every other level is core: a level that -#: becomes skippable by accident would silently drop content from the course. -#: (1.7's maze is repetition too, but `test_section1_level1_7_validation.py` -#: deliberately keeps it mandatory.) -_DELIBERATE_BONUS_LEVELS = { - "5.2": "repeats the 5.1 `ls -l` size column, adds only scanning a longer listing", -} - - -def test_only_deliberate_bonus_levels_are_optional_or_extension() -> None: - levels = _all_levels() - for level in levels: - if str(level.id) in _DELIBERATE_BONUS_LEVELS: - assert level.is_bonus, f"Level {level.id} is listed as bonus but is not marked optional/extension" - continue - assert not level.optional, f"Level {level.id} has optional=True" - assert not level.extension, f"Level {level.id} has extension=True" - assert not level.is_bonus, f"Level {level.id} has is_bonus=True" - - -@pytest.mark.parametrize("level", _all_levels(), ids=lambda level: level.id) -def test_intro_metadata_matches_public_id(level: Level) -> None: - assert level.is_intro is str(level.id).endswith(".0") - - -@pytest.mark.parametrize("level", _all_levels(), ids=lambda level: level.id) -def test_regular_levels_have_declarative_or_custom_completion(level: Level) -> None: - if level.is_intro: - return - - assert level.completion is not None or type(level).validate is not Level.validate - - -def test_default_answer_feedback_never_reveals_the_answer() -> None: - """A rule the author left unconfigured must reject without giving the game away. - - Integer and list answers used to fall back to "Očekáváno X, obdrženo Y", - which handed the solution to any player who guessed once. The default is now - uniformly terse; an author who wants to narrow the search writes the hint - themselves. - """ - initialize_levels() - offenders: list[str] = [] - - for level in get_registry().list_levels(): - completion = level.completion - if completion is None or completion.answer is None: - continue - for rule in _answer_rules(completion.answer): - expected = getattr(rule, "expected", None) - if expected is None or getattr(rule, "error_message", None) is not None: - continue - wrong = "9999999" if isinstance(expected, int) else "\u017e\u017e\u017e-\u0161patn\u011b" - passed, message = rule.validate(wrong) - assert not passed - if message != Messages.INCORRECT: - offenders.append(f"{level.id}: {message}") - - assert not offenders, "These rules use a default rejection that is not the terse one:\n " + "\n ".join(offenders) - - -def _answer_rules(rule: Any) -> list[Any]: - parts = getattr(rule, "parts", None) - if parts is None: - return [rule] - return [nested for part in parts for nested in _answer_rules(part)] diff --git a/tests/test_level_safeguards.py b/tests/test_level_safeguards.py deleted file mode 100644 index 801d331..0000000 --- a/tests/test_level_safeguards.py +++ /dev/null @@ -1,320 +0,0 @@ -from dataclasses import dataclass -from datetime import datetime -from pathlib import Path - -import pytest - -from shellgame.levels.base import Level -from shellgame.levels.cdpolicy import cd_marker -from shellgame.levels.completion import Completion, ExactAnswer -from shellgame.levels.sections.section1 import ( - CdUpLevel, - MultiLevelAscentLevel, - SummaryLevel, -) -from shellgame.levels.sections.section2 import ( - DeepRelativeNavigationLevel, - PreviousDirectoryToggleLevel, -) -from shellgame.levels.sections.section9 import SectionSummaryChallengeLevel -from shellgame.levels.sections.section10 import DevNullLevel, StreamsChallengeLevel -from shellgame.levels.sections.section11 import ( - FinalChallengeLevel, - GrepConfigLineToFileLevel, -) -from shellgame.markers import MarkerManager -from shellgame.messages import Messages -from shellgame.state.manager import GameState - - -def _state(workspace: Path) -> GameState: - return GameState( - username="tester", - workspace=workspace, - current_level="test", - start_time=datetime.now(), - ) - - -@dataclass(frozen=True) -class _NavigationCase: - level: Level - source: str - destination: str - target: str - answer: str | None - marker_name: str - needs_pwd: bool = False - - -class _ExpectedAnswerLevel(Level): - title = "Test" - completion = Completion(answer=ExactAnswer("correct", mistakes={"near": "specific feedback"})) - - -@pytest.mark.parametrize( - "case", - [ - _NavigationCase( - level=CdUpLevel(), - source="level-1/alpha", - destination="level-1", - target="..", - answer="level-1", - marker_name=cd_marker("1.4"), - ), - _NavigationCase( - level=MultiLevelAscentLevel(), - source="level-1/gamma/deep/a/b/c", - destination="level-1/gamma/deep", - target="../../..", - answer=None, - marker_name=cd_marker("1.6"), - ), - _NavigationCase( - level=PreviousDirectoryToggleLevel(), - source="level-2/location-B", - destination="level-2/location-A", - target="-", - answer=None, - marker_name=cd_marker("2.2"), - ), - _NavigationCase( - level=DeepRelativeNavigationLevel(), - source="level-2/deep/structure/start", - destination="level-2/deep/other/target", - target="../../other/target", - answer=None, - marker_name=cd_marker("2.3"), - ), - _NavigationCase( - level=SummaryLevel(), - source="level-1/gamma/deep/a/b/c", - destination="level-1/gamma", - target="../../../..", - answer=None, - marker_name=cd_marker("1.12"), - needs_pwd=True, - ), - ], -) -def test_navigation_command_evidence_and_exact_destination( - case: _NavigationCase, - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - workspace = tmp_path / "workspace" - workspace.mkdir() - case.level.prepare(workspace) - state = _state(workspace) - markers = MarkerManager(workspace) - if case.needs_pwd: - markers.create(MarkerManager.PWD_USED) - - source_path = workspace / case.source - monkeypatch.chdir(source_path) - assert case.level.validate(case.answer, state)[0] is False - - hook = case.level.hooks["cd"] - hook(target=case.target, pwd=str(source_path), post_move=False, state=state) - - assert markers.exists(case.marker_name) - - destination_path = workspace / case.destination - monkeypatch.chdir(destination_path) - assert case.level.validate(case.answer, state)[0] is True - - decoy = tmp_path / "outside" / destination_path.name - decoy.mkdir(parents=True) - monkeypatch.chdir(decoy) - assert case.level.validate(case.answer, state)[0] is False - - -def test_specific_mistake_feedback_runs_before_generic_answer_error(tmp_path: Path) -> None: - level = _ExpectedAnswerLevel() - - success, message = level.validate("near", _state(tmp_path)) - - assert success is False - assert message == "specific feedback" - - -def test_dev_null_level_requires_suppressed_execution(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - workspace = tmp_path / "workspace" - workspace.mkdir() - level = DevNullLevel() - level.prepare(workspace) - state = _state(workspace) - script = workspace / "level-10" / "buggy.sh" - - assert '"$SHELLGAME_FD_HOOK"' in script.read_text() - level.record_fd_evidence( - stdout_target="/dev/null", - stderr_target="/dev/null", - state=state, - ) - - monkeypatch.chdir(workspace / "level-10") - assert level.validate("/dev/null", state)[0] is True - - -def test_dev_null_level_rejects_answer_without_execution(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - workspace = tmp_path / "workspace" - workspace.mkdir() - level = DevNullLevel() - level.prepare(workspace) - state = _state(workspace) - - monkeypatch.chdir(workspace / "level-10") - assert level.validate("/dev/null", state)[0] is False - - -def test_grep_level_requires_exact_matching_line(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - workspace = tmp_path / "workspace" - workspace.mkdir() - level = GrepConfigLineToFileLevel() - level.prepare(workspace) - state = _state(workspace) - level_dir = workspace / "level-11" / "grep" - config_lines = (level_dir / "config.txt").read_text(encoding="utf-8").splitlines() - expected_line = next(line for line in config_lines if "ACTIVE_PROFILE" in line) - target = level_dir / "profile.txt" - target.write_text(expected_line + "\n", encoding="utf-8") - - monkeypatch.chdir(level_dir) - assert level.validate("profile.txt", state)[0] is True - - target.write_text(expected_line.split("=", 1)[-1], encoding="utf-8") - assert level.validate("profile.txt", state)[0] is False - - -def test_grep_level_reports_missing_source_without_crashing(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - workspace = tmp_path / "workspace" - workspace.mkdir() - level = GrepConfigLineToFileLevel() - level.prepare(workspace) - state = _state(workspace) - level_dir = workspace / "level-11" / "grep" - (level_dir / "profile.txt").write_text("anything", encoding="utf-8") - (level_dir / "config.txt").unlink() - - monkeypatch.chdir(level_dir) - success, message = level.validate("profile.txt", state) - - assert success is False - assert "shellgame reset" in message - - -@pytest.mark.parametrize("path", ["profile.txt", "config.txt"]) -@pytest.mark.parametrize("damage", ["directory", "invalid-utf8", "symlink"]) -def test_grep_level_rejects_malformed_files_and_reset_restores_them( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, path: str, damage: str -) -> None: - workspace = tmp_path / "workspace" - workspace.mkdir() - level = GrepConfigLineToFileLevel() - level.prepare(workspace) - level_dir = level.section_path(workspace) / "grep" - state = _state(workspace) - source = level_dir / "config.txt" - output = level_dir / "profile.txt" - output.write_text( - next(line for line in source.read_text().splitlines() if "ACTIVE_PROFILE" in line), - encoding="utf-8", - ) - target = level_dir / path - target.unlink() - outside = tmp_path / "untouched.txt" - outside.write_text("keep", encoding="utf-8") - if damage == "directory": - target.mkdir() - (target / "nested.txt").write_text("mistake", encoding="utf-8") - elif damage == "invalid-utf8": - target.write_bytes(b"\xff") - else: - target.symlink_to(outside) - monkeypatch.chdir(level_dir) - - success, message = level.validate("profile.txt", state) - assert not success - assert Messages.CWD_MISSING not in message - assert "shellgame reset" in message or message == Messages.PATH_ESCAPES_WORKSPACE - - level.reset(workspace) - assert source.is_file() and not source.is_symlink() - assert not output.exists() - assert outside.read_text() == "keep" - assert not level.validate("profile.txt", state)[0] - assert level.solution is not None - level.solution.perform(level, state) - assert level.validate("profile.txt", state)[0] - - -def test_grep_level_handles_unreadable_output(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - level = GrepConfigLineToFileLevel() - level.prepare(tmp_path) - state = _state(tmp_path) - output = level.section_path(tmp_path) / "grep" / "profile.txt" - output.write_text("anything", encoding="utf-8") - original_read = Path.read_text - - def read_text(path: Path, encoding: str | None = None, errors: str | None = None) -> str: - if path == output: - raise PermissionError("Permission denied") - return original_read(path, encoding=encoding, errors=errors) - - monkeypatch.setattr(Path, "read_text", read_text) - success, message = level.validate("profile.txt", state) - assert not success - assert "shellgame reset" in message - assert Messages.CWD_MISSING not in message - - -def test_redirection_challenge_requires_created_content( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - level = SectionSummaryChallengeLevel() - level.prepare(tmp_path) - state = _state(tmp_path) - challenge = tmp_path / "level-9" / "challenge" - monkeypatch.chdir(challenge) - - assert level.validate("3", state)[0] is False - - (challenge / "message.txt").write_text("Hello World\nGoodbye\n", encoding="utf-8") - assert level.validate("3", state)[0] is True - - -def test_streams_challenge_requires_both_output_files( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - level = StreamsChallengeLevel() - level.prepare(tmp_path) - state = _state(tmp_path) - challenge = tmp_path / "level-10" / "challenge" - monkeypatch.chdir(challenge) - - assert level.validate("2,3", state)[0] is False - - (challenge / "errors.log").write_text("error 1\nerror 2\n", encoding="utf-8") - (challenge / "output.log").write_text("line 1\nline 2\nline 3\n", encoding="utf-8") - assert level.validate("2,3", state)[0] is True - - -def test_final_challenge_requires_secret_script_copy( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - level = FinalChallengeLevel() - level.prepare(tmp_path) - state = _state(tmp_path) - final = tmp_path / "level-11" / "final" - monkeypatch.chdir(final) - - assert level.validate("4,NINJA2024", state)[0] is False - - source = final / "hidden" / "secret.sh" - (final / "found" / "secret.txt").write_bytes(source.read_bytes()) - assert level.validate("4,NINJA2024", state)[0] is True diff --git a/tests/test_levels_listing.py b/tests/test_levels_listing.py deleted file mode 100644 index 444db4b..0000000 --- a/tests/test_levels_listing.py +++ /dev/null @@ -1,213 +0,0 @@ -"""Tests for `shellgame levels` and the choices `resume` offers. - -A player who does not know the level IDs has no way to guess them, so the -listing is the entry point to `resume` and must work before the game shell -exists — and without dragging one up behind it. -""" - -from __future__ import annotations - -from datetime import datetime -from pathlib import Path -from typing import Any - -import pytest -from click.testing import CliRunner - -from shellgame.cli import commands -from shellgame.core.session import GameSession -from shellgame.state.manager import GameState, LevelCompletion -from tests.fakes import ( - _FakeConsole, - _FakeDisplay, - _FakeLevel, - _FakeRegistry, - _FakeShell, - _FakeStateManager, - _FakeWorkspace, -) - - -def _make_state(tmp_path: Path, current_level: str = "1.1") -> GameState: - return GameState( - username="student", - current_level=current_level, - workspace=tmp_path / "ws", - start_time=datetime.now(), - ) - - -def _make_session( - tmp_path: Path, levels: list[_FakeLevel], state: GameState | None -) -> tuple[GameSession, _FakeDisplay]: - workspace_root = tmp_path / "ws" - workspace_root.mkdir(parents=True, exist_ok=True) - display = _FakeDisplay() - session = GameSession( - console=_FakeConsole(), - display=display, # type: ignore[arg-type] - state_manager=_FakeStateManager(state), # type: ignore[arg-type] - level_registry=_FakeRegistry(levels), # type: ignore[arg-type] - teleport_notice=lambda _destination: None, - shell_client=_FakeShell(), # type: ignore[arg-type] - workspace_factory=lambda _workspace: _FakeWorkspace(workspace_root), # type: ignore[arg-type] - ) - return session, display - - -def _levels() -> list[_FakeLevel]: - return [_FakeLevel("1.1"), _FakeLevel("1.2"), _FakeLevel("2.0"), _FakeLevel("2.1")] - - -def test_levels_lists_every_registered_level(tmp_path: Path) -> None: - session, display = _make_session(tmp_path, _levels(), _make_state(tmp_path)) - - session.levels() - - args, _kwargs = display.call("show_levels") - assert [level.id for level in args[0]] == ["1.1", "1.2", "2.0", "2.1"] - - -def test_levels_marks_where_the_player_is(tmp_path: Path) -> None: - state = _make_state(tmp_path, current_level="2.0") - state.levels_complete["1.1"] = LevelCompletion(time_sec=1, hints=0, attempts=0, completed_at=datetime.now()) - session, display = _make_session(tmp_path, _levels(), state) - - session.levels() - - _args, kwargs = display.call("show_levels") - assert kwargs["current_level"] == "2.0" - assert kwargs["completed"] == {"1.1"} - - -def test_levels_works_before_the_game_is_initialized(tmp_path: Path) -> None: - """The listing is how a player finds a level, so it cannot require a save.""" - session, display = _make_session(tmp_path, _levels(), None) - - session.levels() - - _args, kwargs = display.call("show_levels") - assert kwargs["current_level"] is None - assert kwargs["completed"] == set() - assert "show_not_initialized" not in display.names() - - -def test_resume_without_a_level_reports_the_usage_and_the_listing(tmp_path: Path) -> None: - """Missing ID is a usage error, but the IDs cannot be guessed either.""" - session, display = _make_session(tmp_path, _levels(), _make_state(tmp_path)) - - session.resume(None) - - assert "show_missing_level_id" in display.names() - assert "show_levels" in display.names() - - -def test_resume_without_a_level_changes_nothing(tmp_path: Path) -> None: - state = _make_state(tmp_path, current_level="1.1") - session, _display = _make_session(tmp_path, _levels(), state) - - session.resume(None) - - assert state.current_level == "1.1" - - -@pytest.mark.parametrize("level_id", [None, "nonsense", "9.9"]) -def test_resume_reports_failure_to_the_caller(tmp_path: Path, level_id: str | None) -> None: - """The CLI turns this into a non-zero exit status.""" - session, _display = _make_session(tmp_path, _levels(), _make_state(tmp_path)) - - assert session.resume(level_id) is False - - -def test_resume_reports_success_to_the_caller(tmp_path: Path) -> None: - session, _display = _make_session(tmp_path, _levels(), _make_state(tmp_path)) - - assert session.resume("2.1") is True - - -def test_cli_resume_without_a_level_exits_non_zero(monkeypatch: Any) -> None: - runner = CliRunner() - - class _StubSession: - def resume(self, level_id: str | None) -> bool: - assert level_id is None - return False - - monkeypatch.setattr(commands, "_get_session", lambda: _StubSession()) - - result = runner.invoke(commands.cli, ["resume"], env={"SHELLGAME_WRAPPER": "1"}) - - assert result.exit_code == 1 - - -def test_cli_resume_with_a_level_exits_zero(monkeypatch: Any) -> None: - runner = CliRunner() - - class _StubSession: - def resume(self, level_id: str | None) -> bool: - return True - - monkeypatch.setattr(commands, "_get_session", lambda: _StubSession()) - - result = runner.invoke(commands.cli, ["resume", "2.1"], env={"SHELLGAME_WRAPPER": "1"}) - - assert result.exit_code == 0 - - -def test_unknown_level_points_at_the_listing(tmp_path: Path) -> None: - session, display = _make_session(tmp_path, _levels(), _make_state(tmp_path)) - - session.resume("9.9") - - assert "show_unknown_level" in display.names() - - -class _FakeContext: - def __init__(self, invoked_subcommand: str | None) -> None: - self.invoked_subcommand = invoked_subcommand - - -@pytest.fixture -def _registry(monkeypatch: Any) -> None: - monkeypatch.setattr(commands, "level_registry", _FakeRegistry(_levels())) - - -def _skips_shell(argv: list[str], invoked: str, monkeypatch: Any) -> bool: - monkeypatch.setattr(commands.sys, "argv", ["shellgame", *argv]) - return commands._runs_without_game_shell(_FakeContext(invoked)) # type: ignore[arg-type] - - -@pytest.mark.usefixtures("_registry") -@pytest.mark.parametrize( - ("argv", "invoked"), - [ - (["levels"], "levels"), - (["resume"], "resume"), - (["resume", "9.9"], "resume"), - (["resume", "nonsense"], "resume"), - (["resume", "--help"], "resume"), - (["resume", "1.1", "2.1"], "resume"), - ], -) -def test_printing_only_invocations_never_launch_a_subshell(argv: list[str], invoked: str, monkeypatch: Any) -> None: - """These only print, so trapping the player in a subshell would be wrong.""" - assert _skips_shell(argv, invoked, monkeypatch) is True - - -@pytest.mark.usefixtures("_registry") -@pytest.mark.parametrize( - ("argv", "invoked"), - [ - (["resume", "2.1"], "resume"), - (["status"], "status"), - (["hint"], "hint"), - (["submit", "answer"], "submit"), - ], -) -def test_gameplay_invocations_still_launch_the_game_shell(argv: list[str], invoked: str, monkeypatch: Any) -> None: - assert _skips_shell(argv, invoked, monkeypatch) is False - - -def test_bare_launch_still_starts_the_game(monkeypatch: Any) -> None: - monkeypatch.setattr(commands.sys, "argv", ["shellgame"]) - assert commands._runs_without_game_shell(_FakeContext(None)) is False # type: ignore[arg-type] diff --git a/tests/test_marker_invariants.py b/tests/test_marker_invariants.py deleted file mode 100644 index 37067de..0000000 --- a/tests/test_marker_invariants.py +++ /dev/null @@ -1,193 +0,0 @@ -"""Invariants that keep evidence markers from silently breaking a level. - -Markers are flat dotfiles in the workspace root. Two authoring mistakes are -invisible at runtime and impossible to spot in review: - -* a marker that is *required* but never *written* makes its level permanently - unsatisfiable; -* a marker that is written but never cleared by its owning level's - ``prepare()`` leaks across a ``reset`` and pre-satisfies the level. - -Both are structural properties of the source, so they are checked statically. -""" - -from __future__ import annotations - -import ast -from pathlib import Path - -import pytest - -from shellgame.levels.base import Level -from shellgame.levels.cdpolicy import CD_EVIDENCE, cd_marker -from shellgame.levels.loader import initialize_levels -from shellgame.levels.registry import LevelRegistry, get_registry -from shellgame.markers import MarkerManager - -SECTIONS_DIR = Path(__file__).resolve().parent.parent / "src" / "shellgame" / "levels" / "sections" -TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "src" / "shellgame" / "cli" / "templates" - -MARKER_CONSTANTS = { - name: value for name, value in vars(MarkerManager).items() if name.isupper() and isinstance(value, str) -} - - -def _marker_value(node: ast.expr) -> str | None: - """Resolve ``MarkerManager.SOME_CONST`` (or a literal) to its marker name.""" - if isinstance(node, ast.Attribute) and node.attr in MARKER_CONSTANTS: - return MARKER_CONSTANTS[node.attr] - if isinstance(node, ast.Constant) and isinstance(node.value, str): - return node.value if node.value in MARKER_CONSTANTS.values() else None - return None - - -def _class_markers(class_node: ast.ClassDef) -> tuple[set[str], set[str]]: - """Return the markers a level class writes and the ones it merely reads.""" - written: set[str] = set() - referenced: set[str] = set() - - for node in ast.walk(class_node): - if isinstance(node, ast.Attribute) and node.attr in MARKER_CONSTANTS: - referenced.add(MARKER_CONSTANTS[node.attr]) - if ( - isinstance(node, ast.Call) - and isinstance(node.func, ast.Attribute) - and node.func.attr == "create" - and node.args - and (marker := _marker_value(node.args[0])) is not None - ): - written.add(marker) - - return written, referenced - - -def _level_classes() -> dict[str, ast.ClassDef]: - """Map every level class name to its AST node, across all section modules.""" - classes: dict[str, ast.ClassDef] = {} - for module_path in sorted(SECTIONS_DIR.glob("section*.py")): - tree = ast.parse(module_path.read_text(encoding="utf-8")) - for node in ast.walk(tree): - if isinstance(node, ast.ClassDef): - classes[f"{module_path.stem}.{node.name}"] = node - return classes - - -def _template_markers() -> set[str]: - """Markers written directly by the shell integration templates.""" - written: set[str] = set() - for template in TEMPLATES_DIR.glob("*_integration.template"): - text = template.read_text(encoding="utf-8") - for marker in MARKER_CONSTANTS.values(): - if f".{marker}" in text: - written.add(marker) - return written - - -@pytest.fixture(scope="module") -def registry() -> LevelRegistry: - initialize_levels() - return get_registry() - - -@pytest.fixture(scope="module") -def written_by_class(registry: LevelRegistry) -> dict[str, set[str]]: - """Markers each level writes, whether by hand or through its `cd_policy`.""" - written = {name: _class_markers(node)[0] for name, node in _level_classes().items()} - by_class_name = {type(level).__name__: level for level in registry.list_levels()} - for qualified_name, markers in written.items(): - level = by_class_name.get(qualified_name.split(".", 1)[1]) - if level is not None and level.cd_policy is not None: - markers.add(level.cd_policy.marker) - return written - - -def test_policy_markers_are_derived_from_the_level_id(registry: LevelRegistry) -> None: - """Auto-derived names make collisions impossible by construction.""" - policy_levels = [level for level in registry.list_levels() if level.cd_policy is not None] - - assert policy_levels, "No level uses cd_policy; the derivation is untested" - for level in policy_levels: - assert level.cd_policy is not None - assert level.cd_policy.marker == cd_marker(level.id), ( - f"Level {level.id} has a hand-written cd marker; let the ID derive it" - ) - - -def test_policy_evidence_placeholders_are_all_bound(registry: LevelRegistry) -> None: - """An unbound placeholder would be checked as a literal, never-written name.""" - for level in registry.list_levels(): - markers = level.completion.evidence_markers if level.completion else () - assert CD_EVIDENCE not in markers, f"Level {level.id} has an unbound CdEvidence placeholder" - if level.cd_policy is not None: - assert level.cd_policy.marker != CD_EVIDENCE, f"Level {level.id} has an unbound cd_policy marker" - - -def test_marker_constants_are_unique() -> None: - """Two constants sharing a value would let one level satisfy another.""" - values = list(MARKER_CONSTANTS.values()) - duplicates = {value for value in values if values.count(value) > 1} - assert not duplicates, f"Marker values reused across constants: {sorted(duplicates)}" - - -def test_every_required_marker_is_written_somewhere( - registry: LevelRegistry, written_by_class: dict[str, set[str]] -) -> None: - """A required marker nobody writes makes its level impossible to finish.""" - produced = set(_template_markers()) - for markers in written_by_class.values(): - produced |= markers - - unsatisfiable: list[tuple[str, str]] = [] - for level in registry.list_levels(): - for marker in _required_markers(level): - if marker not in produced: - unsatisfiable.append((level.id, marker)) - - assert not unsatisfiable, ( - "These levels require evidence markers that nothing ever creates, so they " - f"can never be completed: {unsatisfiable}" - ) - - -def test_every_written_marker_is_cleared_by_its_level( - registry: LevelRegistry, written_by_class: dict[str, set[str]] -) -> None: - """A marker that survives ``prepare()`` pre-satisfies the level after a reset.""" - by_class_name = {type(level).__name__: level for level in registry.list_levels()} - - leaked: list[tuple[str, str]] = [] - for qualified_name, markers in written_by_class.items(): - class_name = qualified_name.split(".", 1)[1] - level = by_class_name.get(class_name) - if level is None: - continue - cleared = _cleared_markers(level) - for marker in markers: - if marker not in cleared: - leaked.append((level.id, marker)) - - assert not leaked, ( - "These levels write evidence markers that their own prepare() does not clear, " - "so the evidence survives `shellgame reset` and pre-satisfies the level: " - f"{leaked}. Add the marker to `reset_markers` or to an `Evidence` requirement." - ) - - -def test_each_marker_has_at_most_one_writing_level(written_by_class: dict[str, set[str]]) -> None: - """Shared write sites make one level's actions satisfy a different level.""" - owners: dict[str, list[str]] = {} - for qualified_name, markers in written_by_class.items(): - for marker in markers: - owners.setdefault(marker, []).append(qualified_name) - - shared = {marker: names for marker, names in owners.items() if len(names) > 1} - assert not shared, f"Markers written by more than one level class: {shared}" - - -def _required_markers(level: Level) -> set[str]: - return set(level.completion.evidence_markers) if level.completion else set() - - -def _cleared_markers(level: Level) -> set[str]: - completion_markers = level.completion.evidence_markers if level.completion else () - return {name for name in (*level.reset_markers, *completion_markers) if name} diff --git a/tests/test_navigation_copy.py b/tests/test_navigation_copy.py deleted file mode 100644 index 6c73f77..0000000 --- a/tests/test_navigation_copy.py +++ /dev/null @@ -1,88 +0,0 @@ -"""Replay navigation directions from the player's declared starting location.""" - -import re -import subprocess -from datetime import datetime -from pathlib import Path - -import pytest - -from shellgame.levels.sections.section1 import AbsoluteCdLevel, SummaryLevel -from shellgame.levels.sections.section2 import SectionChallengeLevel -from shellgame.state.manager import GameState - - -@pytest.mark.parametrize("reset", [False, True], ids=["fresh", "reset"]) -def test_summary_final_hint_path_is_reachable_from_start(tmp_path: Path, reset: bool) -> None: - level = SummaryLevel() - level.prepare(tmp_path) - if reset: - level.reset(tmp_path) - start = level.get_start_directory(tmp_path) - assert start is not None - commands = re.findall(r"`(cd [^`]+)`", level.hints[-1]) - assert len(commands) == 2 - - result = subprocess.run( - ["bash", "-c", f"{commands[0]} && pwd -P"], - cwd=start, - capture_output=True, - text=True, - check=True, - timeout=10, - ) - - assert Path(result.stdout.strip()) == level.section_path(tmp_path) / "gamma/deep/a/b/c" - - -def test_absolute_hint_uses_actual_start_not_example_username(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - level = AbsoluteCdLevel() - workspace = tmp_path / "workspace with spaces" - workspace.mkdir() - level.prepare(workspace) - start = level.get_start_directory(workspace) - assert start is not None - hint_text = " ".join(level.hints) - assert "shellgame-user" not in hint_text - assert "/tmp/" not in hint_text - assert "pwd" in hint_text - assert "'/absolute-target'" in hint_text - destination = Path(f"{start}/absolute-target") - state = GameState(username="tester", workspace=workspace, current_level=level.id, start_time=datetime.now()) - monkeypatch.chdir(start) - assert not level.validate(None, state)[0] - - level.hooks["cd"](target=str(destination), pwd=str(start), post_move=False, state=state) - result = subprocess.run( - ["bash", "-c", 'cd "$1" && pwd -P', "bash", str(destination)], - cwd=start, - capture_output=True, - text=True, - check=True, - timeout=10, - ) - assert Path(result.stdout.strip()) == destination - monkeypatch.chdir(destination) - assert level.validate(None, state)[0] - - -@pytest.mark.parametrize("reset", [False, True], ids=["fresh", "reset"]) -def test_section_two_challenge_clues_lead_to_required_destination(tmp_path: Path, reset: bool) -> None: - level = SectionChallengeLevel() - level.prepare(tmp_path) - if reset: - level.reset(tmp_path) - start = level.get_start_directory(tmp_path) - assert start == level.section_path(tmp_path) - assert "challenge/room1" in level.instructions - assert "room2" in level.instructions - assert "password.txt" in level.instructions - assert "`cd -`" not in level.instructions - - destination = level.section_path(tmp_path) / "challenge/room2" - state = GameState(username="tester", workspace=tmp_path, current_level=level.id, start_time=datetime.now()) - assert not level.validate("navigator", state)[0] - - with pytest.MonkeyPatch.context() as monkeypatch: - monkeypatch.chdir(destination) - assert level.validate("navigator", state)[0] diff --git a/tests/test_path_vocabulary.py b/tests/test_path_vocabulary.py deleted file mode 100644 index d1f598a..0000000 --- a/tests/test_path_vocabulary.py +++ /dev/null @@ -1,171 +0,0 @@ -"""Invariants for the section-relative path vocabulary. - -Every path a level declares - its fixtures, its completion requirements and its -start directory - is resolved against that level's section root. A level that -also spells the section root inside the path resolves to -``level-1/level-1/alpha`` and can never be completed, which is invisible until a -student reaches it. - -These checks make the vocabulary structural instead of conventional. -""" - -from __future__ import annotations - -import ast -import re -from pathlib import Path - -import pytest - -from shellgame.levels.base import Level -from shellgame.levels.completion import ( - AtDirectory, - DirectoryExists, - FileExists, - FileLineCount, - PathMoved, - PathsMatch, - PermissionBits, - PermissionMode, - TextFileContent, -) -from shellgame.levels.fixture import WorkspaceFixture -from shellgame.levels.loader import initialize_levels -from shellgame.levels.registry import get_registry -from shellgame.paths import WorkspaceRoot - -SECTIONS_DIR = Path(__file__).resolve().parent.parent / "src" / "shellgame" / "levels" / "sections" - -SINGLE_PATH_REQUIREMENTS = ( - AtDirectory, - DirectoryExists, - FileExists, - FileLineCount, - PermissionBits, - PermissionMode, - TextFileContent, -) -PAIRED_PATH_REQUIREMENTS = (PathMoved, PathsMatch) - - -def _all_levels() -> list[Level]: - initialize_levels() - return get_registry().list_levels() - - -def _declared_paths(level: Level) -> list[tuple[str, str]]: - """Every path this level declares, as ``(source, value)`` pairs.""" - declared: list[tuple[str, str]] = [] - - if isinstance(level.start_directory, str): - declared.append(("start_directory", level.start_directory)) - - for name, fixture in (("section_fixture", level.section_fixture), ("fixture", level.fixture)): - if isinstance(fixture, WorkspaceFixture): - declared.extend((name, value) for value in fixture.directories) - declared.extend((name, directory.path) for directory in fixture.directory_fixtures) - declared.extend((name, file.path) for file in fixture.files) - declared.extend((name, value) for value in fixture.clean) - - if level.completion is not None: - requirements = [*level.completion.requirements] - if level.completion.allow_empty_when is not None: - requirements.append(level.completion.allow_empty_when) - for requirement in requirements: - name = type(requirement).__name__ - if isinstance(requirement, SINGLE_PATH_REQUIREMENTS): - declared.append((name, requirement.path)) - elif isinstance(requirement, PAIRED_PATH_REQUIREMENTS): - declared.append((name, requirement.source)) - declared.append((name, requirement.destination)) - - return declared - - -@pytest.mark.parametrize("level", _all_levels(), ids=lambda level: level.id) -def test_level_paths_do_not_restate_their_section_root(level: Level) -> None: - """``AtDirectory("level-1/alpha")`` inside section 1 resolves twice over.""" - root = level.section_root - if not root: - return - - offenders = [ - (source, value) for source, value in _declared_paths(level) if value == root or value.startswith(f"{root}/") - ] - - assert not offenders, ( - f"Level {level.id} restates its own section root {root!r}: {offenders}. " - "Level paths are relative to the section root, so drop the prefix." - ) - - -@pytest.mark.parametrize("level", _all_levels(), ids=lambda level: level.id) -def test_level_paths_stay_inside_the_section(level: Level, tmp_path: Path) -> None: - """Declared paths must not climb out of the section with `..` or an anchor.""" - for source, value in _declared_paths(level): - candidate = Path(value) - assert not candidate.is_absolute(), f"Level {level.id}: {source} path {value!r} is absolute" - assert ".." not in candidate.parts, f"Level {level.id}: {source} path {value!r} escapes the section" - - -@pytest.mark.parametrize("level", _all_levels(), ids=lambda level: level.id) -def test_start_directory_resolves_in_the_declared_vocabulary(level: Level, tmp_path: Path) -> None: - """`""` is the section root; only the sentinel reaches the workspace root.""" - workspace = tmp_path / "workspace" - workspace.mkdir() - resolved = level.get_start_directory(workspace) - - if level.start_directory is None: - assert resolved is None - return - - if isinstance(level.start_directory, WorkspaceRoot): - assert resolved == workspace - elif level.start_directory == "": - assert resolved == level.section_path(workspace) - else: - assert resolved == level.section_path(workspace) / level.start_directory - - -def test_workspace_root_starts_are_explicitly_acknowledged() -> None: - """Starting above your own section is deliberate, so it is listed here. - - Both directions matter. A level that quietly gains the sentinel moves the - player out of its own fixtures; a level that quietly loses it starts one - directory too deep. Neither shows up as a test failure anywhere else, - because both destinations exist and both are inside the workspace. - """ - expected = { - "1.0", # section intro: the player has not entered section 1 yet - "1.9", # walking home from the workspace root - "1.10", # absolute paths, taught from the workspace root - } - actual = {level.id for level in _all_levels() if isinstance(level.start_directory, WorkspaceRoot)} - - assert actual == expected, ( - "Levels starting at the workspace root instead of their section root changed. " - f"Added: {sorted(actual - expected)}; removed: {sorted(expected - actual)}. " - "Confirm the new placement is intended, then update this list." - ) - - -def test_section_modules_do_not_hardcode_their_own_root() -> None: - """Code that builds paths by hand must go through `section_path()`.""" - offenders: list[str] = [] - for module_path in sorted(SECTIONS_DIR.glob("section*.py")): - number = int(re.search(r"section(\d+)\.py$", module_path.name).group(1)) - root = "" if number == 0 else f"level-{number}" - if not root: - continue - - tree = ast.parse(module_path.read_text(encoding="utf-8")) - for node in ast.walk(tree): - if not isinstance(node, ast.BinOp) or not isinstance(node.op, ast.Div): - continue - if isinstance(node.right, ast.Constant) and node.right.value == root: - offenders.append(f"{module_path.name}:{node.lineno}") - - assert not offenders, ( - "These lines join the section root onto a workspace path by hand; " - f"use `self.section_path(workspace)` instead: {offenders}" - ) diff --git a/tests/test_pending_command_forwarding.py b/tests/test_pending_command_forwarding.py deleted file mode 100644 index 458a63d..0000000 --- a/tests/test_pending_command_forwarding.py +++ /dev/null @@ -1,185 +0,0 @@ -"""A command typed outside the game shell must survive the subshell launch. - -`shellgame resume 6.2` in an ordinary terminal has to start the game subshell -first, which ends the original process before Click ever reaches the -subcommand. Without forwarding, what the player typed is silently dropped and -they land on their old level instead. -""" - -from __future__ import annotations - -import os -import shlex -import shutil -import subprocess -import sys -from pathlib import Path -from typing import Any - -import pytest - -from shellgame.cli import boot, commands -from shellgame.cli.subshell import _autostart_command, get_bash_integration, get_fish_integration - - -class _FakeContext: - def __init__(self, invoked_subcommand: str | None) -> None: - self.invoked_subcommand = invoked_subcommand - - -def _pending(argv: list[str], invoked: str | None, monkeypatch: Any) -> list[str]: - monkeypatch.setattr(sys, "argv", ["shellgame", *argv]) - return commands._pending_command(_FakeContext(invoked)) # type: ignore[arg-type] - - -def test_pending_command_captures_subcommand_and_arguments(monkeypatch: Any) -> None: - assert _pending(["resume", "6.2"], "resume", monkeypatch) == ["resume", "6.2"] - - -def test_pending_command_skips_group_level_options(monkeypatch: Any) -> None: - assert _pending(["--shell", "bash", "resume", "6.2"], "resume", monkeypatch) == ["resume", "6.2"] - - -def test_pending_command_is_empty_for_a_bare_launch(monkeypatch: Any) -> None: - assert _pending([], None, monkeypatch) == [] - - -def test_pending_command_gives_up_rather_than_guessing(monkeypatch: Any) -> None: - """A resolved name that was never typed must not be forwarded as a guess.""" - assert _pending(["--shell", "bash"], "resume", monkeypatch) == [] - - -@pytest.mark.parametrize( - ("shell_name", "expected"), - [ - ("bash", "resume 6.2"), - ("fish", "'resume' '6.2'"), - ], -) -def test_autostart_command_quotes_per_shell(shell_name: str, expected: str) -> None: - assert _autostart_command(shell_name, ["resume", "6.2"]) == expected - - -def test_autostart_command_is_empty_without_a_pending_command() -> None: - assert _autostart_command("bash", None) == "" - assert _autostart_command("bash", []) == "" - - -@pytest.mark.skipif(shutil.which("bash") is None, reason="bash not installed") -def test_bash_quoting_survives_spaces_and_quotes() -> None: - """The rendered string must re-split into the exact original arguments.""" - argv = ["submit", "a b 'c'", "d\\e"] - - rendered = _autostart_command("bash", argv) - - assert shlex.split(rendered) == argv - - -@pytest.mark.skipif(shutil.which("fish") is None, reason="fish not installed") -def test_fish_quoting_survives_spaces_and_quotes(tmp_path: Path) -> None: - """fish escapes differently from POSIX, so real fish has to confirm it.""" - argv = ["submit", "a b 'c'", "d\\e"] - recorder = tmp_path / "args.txt" - - rendered = _autostart_command("fish", argv) - script = ( - f"function shellgame; printf '%s\\n' $argv > {shlex.quote(str(recorder))}; end; eval \"shellgame {rendered}\"" - ) - subprocess.run(["fish", "-c", script], check=True, capture_output=True, text=True) - - assert recorder.read_text(encoding="utf-8").splitlines() == argv - - -def test_boot_forwards_the_pending_command_to_the_subshell(monkeypatch: Any) -> None: - launched: dict[str, Any] = {} - - def fake_launch(shell_name: str, devmode: bool = False, *, pending_command: list[str] | None = None) -> None: - launched["shell"] = shell_name - launched["pending"] = pending_command - - monkeypatch.setattr(boot, "launch_subshell", fake_launch) - monkeypatch.setenv("SHELLGAME_FORCE_SHELL", "bash") - - result = boot.boot_if_needed(wrapped=False, devmode=False, pending_command=["resume", "6.2"]) - - assert result.should_exit is True - assert launched["pending"] == ["resume", "6.2"] - - -def test_boot_inside_the_game_shell_does_not_relaunch(monkeypatch: Any) -> None: - def fail_launch(*args: Any, **kwargs: Any) -> None: - raise AssertionError("must not launch a subshell when already wrapped") - - monkeypatch.setattr(boot, "launch_subshell", fail_launch) - - result = boot.boot_if_needed(wrapped=True, devmode=False, pending_command=["resume", "6.2"]) - - assert result.should_exit is False - - -def _recording_binary(tmp_path: Path, recorder: Path) -> str: - stub = tmp_path / "stub.py" - stub.write_text( - "import sys, pathlib\n" - f"pathlib.Path({str(recorder)!r}).write_text(chr(10).join(sys.argv[1:]), encoding='utf-8')\n", - encoding="utf-8", - ) - return " ".join(shlex.quote(part) for part in [sys.executable, str(stub)]) - - -def _source_in_bash(script: Path) -> None: - subprocess.run( - ["bash", "--noprofile", "--norc", "-c", f"source {shlex.quote(str(script))}"], - check=False, - capture_output=True, - text=True, - env=os.environ.copy(), - ) - - -@pytest.mark.skipif(shutil.which("bash") is None, reason="bash not installed") -@pytest.mark.parametrize("argv", [["resume", "6.2"], ["submit", "a b 'c'"], ["levels"]]) -def test_bash_autostart_runs_the_forwarded_command(tmp_path: Path, argv: list[str]) -> None: - """End to end in real bash: the forwarded args reach the game binary intact.""" - recorder = tmp_path / "args.txt" - script = tmp_path / "integration.bash" - script.write_text( - get_bash_integration(_recording_binary(tmp_path, recorder), pending_command=argv), - encoding="utf-8", - ) - - _source_in_bash(script) - - assert recorder.read_text(encoding="utf-8").splitlines() == argv - - -@pytest.mark.skipif(shutil.which("bash") is None, reason="bash not installed") -def test_bash_autostart_without_forwarding_starts_the_game_plainly(tmp_path: Path) -> None: - recorder = tmp_path / "args.txt" - script = tmp_path / "integration.bash" - script.write_text(get_bash_integration(_recording_binary(tmp_path, recorder)), encoding="utf-8") - - _source_in_bash(script) - - assert recorder.read_text(encoding="utf-8") == "" - - -@pytest.mark.skipif(shutil.which("fish") is None, reason="fish not installed") -@pytest.mark.parametrize("argv", [["resume", "6.2"], ["submit", "a b 'c'"]]) -def test_fish_autostart_runs_the_forwarded_command(tmp_path: Path, argv: list[str]) -> None: - recorder = tmp_path / "args.txt" - script = tmp_path / "integration.fish" - script.write_text( - get_fish_integration(_recording_binary(tmp_path, recorder), pending_command=argv), - encoding="utf-8", - ) - - subprocess.run( - ["fish", "-c", f"source {shlex.quote(str(script))}"], - check=False, - capture_output=True, - text=True, - env=os.environ.copy(), - ) - - assert recorder.read_text(encoding="utf-8").splitlines() == argv diff --git a/tests/test_progress.py b/tests/test_progress.py deleted file mode 100644 index aa814b5..0000000 --- a/tests/test_progress.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Unit tests for progress tracking.""" - -from datetime import datetime, timedelta -from pathlib import Path - -from shellgame.core.progress import ProgressTracker -from shellgame.state.manager import StateManager - - -class TestProgressTracker: - """Test ProgressTracker.""" - - def test_record_attempt_increments(self, tmp_path: Path) -> None: - manager = StateManager() - manager.state_dir = tmp_path - manager.state_file = tmp_path / "state.json" - tracker = ProgressTracker() - - state = manager.init("testuser") - tracker.record_attempt(state, level_id="1.1") - tracker.record_attempt(state, level_id="1.1") - tracker.record_attempt(state, level_id="2.0") - - assert state.level_attempts["1.1"] == 2 - assert state.level_attempts["2.0"] == 1 - - def test_ensure_level_started_sets_once(self, tmp_path: Path) -> None: - manager = StateManager() - manager.state_dir = tmp_path - manager.state_file = tmp_path / "state.json" - tracker = ProgressTracker() - - state = manager.init("testuser") - - now = datetime.now() - tracker.ensure_level_started(state, level_id="1.1", now=now) - assert state.level_started_at["1.1"] == now - - later = now + timedelta(seconds=10) - tracker.ensure_level_started(state, level_id="1.1", now=later) - assert state.level_started_at["1.1"] == now - - def test_record_completion_uses_tracking(self, tmp_path: Path) -> None: - manager = StateManager() - manager.state_dir = tmp_path - manager.state_file = tmp_path / "state.json" - tracker = ProgressTracker() - - state = manager.init("testuser") - - started = datetime.now() - timedelta(seconds=12) - state.level_started_at["1.1"] = started - state.level_attempts["1.1"] = 2 - state.level_hints_used["1.1"] = 1 - - completed_at = datetime.now() - completion = tracker.record_completion(state, level_id="1.1", completed_at=completed_at) - - assert state.levels_complete["1.1"] == completion - assert completion.attempts == 2 - assert completion.hints == 1 - assert completion.completed_at == completed_at - assert completion.time_sec >= 12 diff --git a/tests/test_resume.py b/tests/test_resume.py deleted file mode 100644 index 04398dc..0000000 --- a/tests/test_resume.py +++ /dev/null @@ -1,147 +0,0 @@ -"""Tests for `shellgame resume LEVEL`. - -The contract a player relies on: resuming moves them to the requested level -*and* records it, so quitting and launching ShellGame again continues there. -""" - -from __future__ import annotations - -from datetime import datetime -from pathlib import Path - -import pytest - -from shellgame.core.session import GameSession -from shellgame.state.manager import GameState -from tests.fakes import ( - _FakeConsole, - _FakeDisplay, - _FakeLevel, - _FakeRegistry, - _FakeShell, - _FakeStateManager, - _FakeWorkspace, -) - - -def _make_state(tmp_path: Path, current_level: str = "1.1") -> GameState: - return GameState( - username="student", - current_level=current_level, - workspace=tmp_path / "ws", - start_time=datetime.now(), - ) - - -def _make_session( - tmp_path: Path, levels: list[_FakeLevel], state: GameState | None -) -> tuple[GameSession, _FakeDisplay, _FakeStateManager]: - display = _FakeDisplay() - state_manager = _FakeStateManager(state) - workspace_root = tmp_path / "ws" - workspace_root.mkdir(parents=True, exist_ok=True) - session = GameSession( - console=_FakeConsole(), - display=display, # type: ignore[arg-type] - state_manager=state_manager, # type: ignore[arg-type] - level_registry=_FakeRegistry(levels), # type: ignore[arg-type] - teleport_notice=lambda _destination: None, - shell_client=_FakeShell(), # type: ignore[arg-type] - workspace_factory=lambda _workspace: _FakeWorkspace(workspace_root), # type: ignore[arg-type] - ) - return session, display, state_manager - - -def _levels() -> list[_FakeLevel]: - return [_FakeLevel("1.1"), _FakeLevel("1.2"), _FakeLevel("2.0"), _FakeLevel("2.1")] - - -def test_resume_moves_to_requested_level_and_persists_it(tmp_path: Path) -> None: - state = _make_state(tmp_path, current_level="1.1") - session, display, state_manager = _make_session(tmp_path, _levels(), state) - - session.resume("2.1") - - assert state.current_level == "2.1" - assert state_manager.state is not None - assert state_manager.state.current_level == "2.1" - assert state_manager.saves >= 1 - assert "show_resumed" in display.names() - - -def test_relaunch_continues_from_resumed_level(tmp_path: Path) -> None: - """The point of the feature: the next launch starts where resume left off.""" - state = _make_state(tmp_path, current_level="1.1") - session, _, state_manager = _make_session(tmp_path, _levels(), state) - - session.resume("2.0") - - # A fresh process: new session, same persisted state. - relaunched, display, _ = _make_session(tmp_path, _levels(), state_manager.state) - relaunched.show_current_level() - - args, _kwargs = display.call("show_instructions") - assert args[0].id == "2.0" - - -def test_resume_prepares_the_target_level(tmp_path: Path) -> None: - levels = _levels() - target = next(level for level in levels if level.id == "2.1") - session, _, _ = _make_session(tmp_path, levels, _make_state(tmp_path)) - - session.resume("2.1") - - assert target.prepared >= 1 - - -def test_resume_reopens_a_finished_run(tmp_path: Path) -> None: - """Without clearing `completed_at`, every command prints the end screen.""" - state = _make_state(tmp_path, current_level="2.1") - state.completed_at = datetime.now() - session, display, _ = _make_session(tmp_path, _levels(), state) - - session.resume("1.2") - - assert state.completed_at is None - assert state.current_level == "1.2" - assert "show_game_complete" not in display.names() - - -def test_resume_starts_the_clock_for_an_unvisited_level(tmp_path: Path) -> None: - state = _make_state(tmp_path, current_level="1.1") - session, _, _ = _make_session(tmp_path, _levels(), state) - - session.resume("2.1") - - assert "2.1" in state.level_started_at - - -def test_resume_keeps_the_original_start_time_when_revisiting(tmp_path: Path) -> None: - state = _make_state(tmp_path, current_level="2.1") - first_seen = datetime(2020, 1, 1, 12, 0, 0) - state.level_started_at["1.2"] = first_seen - session, _, _ = _make_session(tmp_path, _levels(), state) - - session.resume("1.2") - - assert state.level_started_at["1.2"] == first_seen - - -@pytest.mark.parametrize("level_id", ["9.9", "nope", "1", "1.x", ""]) -def test_resume_rejects_unusable_level_ids_without_touching_state(tmp_path: Path, level_id: str) -> None: - state = _make_state(tmp_path, current_level="1.1") - session, _, state_manager = _make_session(tmp_path, _levels(), state) - - session.resume(level_id) - - assert state.current_level == "1.1" - assert state_manager.saves == 0 - - -def test_resume_without_a_save_reports_not_initialized(tmp_path: Path) -> None: - session, display, state_manager = _make_session(tmp_path, _levels(), None) - - session.resume("1.2") - - assert "show_not_initialized" in display.names() - assert state_manager.saves == 0 diff --git a/tests/test_section1_level1_7_validation.py b/tests/test_section1_level1_7_validation.py deleted file mode 100644 index 1506c7d..0000000 --- a/tests/test_section1_level1_7_validation.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Tests for Level 1.7 validation behavior.""" - -from pathlib import Path - -import pytest - -from shellgame.levels.sections.section1 import MazeLevel - - -def test_level1_7_submit_without_answer_depends_on_exact_cwd(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - level = MazeLevel() - level.prepare(tmp_path) - - # Minimal state stub; Level1_7.validate doesn't use it. - class _State: - def __init__(self, workspace: Path) -> None: - self.workspace = workspace - self.current_level = "1.7" - self.username = "testuser" - - state = _State(tmp_path) - - # Not in final -> fail - monkeypatch.chdir(tmp_path) - ok, _ = level.validate(None, state) - assert ok is False - - # A same-named directory outside the maze must not pass. - decoy = tmp_path / "final" - decoy.mkdir() - monkeypatch.chdir(decoy) - ok2, _ = level.validate(None, state) - assert ok2 is False - - # Only the actual maze destination passes. - final_dir = tmp_path / "level-1" / level._SANCTUARY - monkeypatch.chdir(final_dir) - ok3, _ = level.validate(None, state) - assert ok3 is True - - -def test_level1_7_answer_is_ignored_when_in_final(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - level = MazeLevel() - level.prepare(tmp_path) - - class _State: - def __init__(self, workspace: Path) -> None: - self.workspace = workspace - self.current_level = "1.7" - self.username = "testuser" - - state = _State(tmp_path) - - final_dir = tmp_path / "level-1" / level._SANCTUARY - monkeypatch.chdir(final_dir) - - ok, _ = level.validate("anything", state) - assert ok is True - - -def test_level1_7_is_mandatory() -> None: - level = MazeLevel() - assert not level.optional - assert not level.extension - assert not level.is_bonus diff --git a/tests/test_section1_level1_8_absolute_cd.py b/tests/test_section1_level1_8_absolute_cd.py deleted file mode 100644 index 7444af2..0000000 --- a/tests/test_section1_level1_8_absolute_cd.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Tests for Level 1.8 absolute-cd enforcement logic. - -These tests validate the Python-side gate (marker required) that works together -with the shell wrapper's `cd` interception. -""" - -from __future__ import annotations - -from pathlib import Path -from typing import Any - -from shellgame.levels.cdpolicy import cd_marker -from shellgame.levels.sections.section1 import AbsoluteCdLevel -from shellgame.markers import MarkerManager - - -class _State: - def __init__(self, workspace: Path, username: str) -> None: - self.workspace = workspace - self.username = username - self.current_level = "1.8" - - -def test_level1_8_submit_without_answer_requires_cwd_and_marker(tmp_path: Path, monkeypatch: Any) -> None: - level = AbsoluteCdLevel() - level.prepare(tmp_path) - - state = _State(tmp_path, "tester") - - # In the right directory but no marker -> reject - target = tmp_path / "level-1" / "absolute-target" - monkeypatch.chdir(target) - state.workspace.mkdir(parents=True, exist_ok=True) - markers = MarkerManager(state.workspace) - markers.remove(cd_marker("1.8")) - - ok, _ = level.validate(None, state) - assert ok is False - - # With marker -> accept - markers.create(cd_marker("1.8")) - ok2, _ = level.validate(None, state) - assert ok2 is True - - -def test_level1_8_answer_is_optional_and_ignored(tmp_path: Path, monkeypatch: Any) -> None: - level = AbsoluteCdLevel() - level.prepare(tmp_path) - - state = _State(tmp_path, "tester2") - target = tmp_path / "level-1" / "absolute-target" - monkeypatch.chdir(target) - - state.workspace.mkdir(parents=True, exist_ok=True) - MarkerManager(state.workspace).create(cd_marker("1.8")) - - ok, _ = level.validate("absolute-target", state) - assert ok is True - ok2, _ = level.validate("anything", state) - assert ok2 is True diff --git a/tests/test_section1_level1_9_cd_walk.py b/tests/test_section1_level1_9_cd_walk.py deleted file mode 100644 index ee1b9bb..0000000 --- a/tests/test_section1_level1_9_cd_walk.py +++ /dev/null @@ -1,108 +0,0 @@ -from __future__ import annotations - -import os -from dataclasses import dataclass -from pathlib import Path -from typing import Any - -import pytest - -from shellgame.levels.sections.section1 import HomeWalkLevel -from shellgame.markers import MarkerManager - - -@dataclass -class _StubState: - username: str - workspace: Path - current_level: str = "1.9" - - -def _run_in_cwd(tmp_path: Path, cwd: Path, fn: Any) -> Any: - old = Path.cwd() - try: - # pytest runs in a real filesystem; we can chdir freely - os.chdir(cwd) - return fn() - finally: - os.chdir(old) - - -@pytest.mark.parametrize("completed_walk", [False, True]) -def test_level1_9_rejects_when_not_at_home(tmp_path: Path, monkeypatch: Any, completed_walk: bool) -> None: - level = HomeWalkLevel() - - fake_home = tmp_path / "home" / "student" - (tmp_path / "home" / "student").mkdir(parents=True) - - # Make Path.home() deterministic - monkeypatch.setattr(Path, "home", lambda: fake_home) - - state = _StubState(username="student", workspace=tmp_path) - if completed_walk: - MarkerManager.from_state(state).create(MarkerManager.LEVEL1_9_CD_WALK_COMPLETED) - - def _validate() -> tuple[bool, str]: - return level.validate(None, state) - - ok, msg = _run_in_cwd(tmp_path, tmp_path, _validate) - assert ok is False - assert "Nejste doma" in msg - - -@pytest.mark.parametrize("answer", [None, "", "ignored"]) -def test_level1_9_requires_marker_even_if_at_home(tmp_path: Path, monkeypatch: Any, answer: str | None) -> None: - level = HomeWalkLevel() - - fake_home = tmp_path / "home" / "student" - fake_home.mkdir(parents=True) - monkeypatch.setattr(Path, "home", lambda: fake_home) - - state = _StubState(username="student", workspace=tmp_path) - - state.workspace.mkdir(parents=True, exist_ok=True) - marker = state.workspace / ".level1_9_cd_walk_completed" - if marker.exists(): - marker.unlink() - - ok, msg = _run_in_cwd(tmp_path, fake_home, lambda: level.validate(answer, state)) - assert ok is False - assert "nezaznamenal" in msg - - -def test_level1_9_physical_jump_resets_progress(tmp_path: Path, monkeypatch: Any) -> None: - level = HomeWalkLevel() - fake_home = tmp_path / "home" / "student" - fake_home.mkdir(parents=True) - monkeypatch.setattr(Path, "home", lambda: fake_home) - state = _StubState(username="student", workspace=tmp_path) - markers = MarkerManager.from_state(state) - hook = level.hooks["cd"] - - hook(target=None, pwd="/", post_move=True, state=state) - assert markers.read(MarkerManager.LEVEL1_9_CD_WALK_PROGRESS) == "/" - hook(target=None, pwd=str(fake_home), post_move=True, state=state) - - assert not markers.exists(MarkerManager.LEVEL1_9_CD_WALK_PROGRESS) - assert not markers.exists(MarkerManager.LEVEL1_9_CD_WALK_COMPLETED) - ok, _ = _run_in_cwd(tmp_path, fake_home, lambda: level.validate(None, state)) - assert ok is False - - -def test_level1_9_accepts_submit_without_answer_when_marker_present(tmp_path: Path, monkeypatch: Any) -> None: - level = HomeWalkLevel() - - fake_home = tmp_path / "home" / "student" - fake_home.mkdir(parents=True) - monkeypatch.setattr(Path, "home", lambda: fake_home) - - state = _StubState(username="student", workspace=tmp_path) - - state.workspace.mkdir(parents=True, exist_ok=True) - (state.workspace / ".level1_9_cd_walk_completed").write_text("") - - ok, _ = _run_in_cwd(tmp_path, fake_home, lambda: level.validate(None, state)) - assert ok is True - - ok2, _ = _run_in_cwd(tmp_path, fake_home, lambda: level.validate("ignored", state)) - assert ok2 is True diff --git a/tests/test_section1_level1_9_shell_runtime.py b/tests/test_section1_level1_9_shell_runtime.py deleted file mode 100644 index 464b134..0000000 --- a/tests/test_section1_level1_9_shell_runtime.py +++ /dev/null @@ -1,165 +0,0 @@ -"""Exercise home-walk grading through the real bash/fish wrappers and CLI.""" - -from __future__ import annotations - -import os -import shlex -import shutil -import subprocess -import sys -from dataclasses import dataclass -from datetime import datetime -from pathlib import Path - -import pytest - -from shellgame.cli.subshell import get_bash_integration, get_fish_integration -from shellgame.markers import MarkerManager -from shellgame.state.manager import GameState, StateManager - - -@dataclass -class _HomeWalkShell: - shell: str - integration: Path - home: Path - state: GameState - manager: StateManager - env: dict[str, str] - - @property - def markers(self) -> MarkerManager: - return MarkerManager.from_state(self.state) - - def run(self, commands: str) -> subprocess.CompletedProcess[str]: - flags = ["--no-config"] if self.shell == "fish" else [] - result = subprocess.run( - [self.shell, *flags, "-c", f"source {shlex.quote(str(self.integration))}; {commands}"], - cwd=self.state.workspace, - env=self.env, - check=False, - capture_output=True, - text=True, - input="\n", - timeout=30, - ) - assert result.returncode == 0, result.stdout + result.stderr - assert "__SHELLGAME_EXEC__" not in result.stdout + result.stderr - return result - - def probe_move(self, command: str) -> subprocess.CompletedProcess[str]: - status = "$status" if self.shell == "fish" else "$?" - return self.run(f"{command}; printf 'CD_STATUS=%s\\n' {status}; command pwd -P; shellgame submit") - - def assert_incomplete(self) -> None: - assert not self.markers.exists(MarkerManager.LEVEL1_9_CD_WALK_COMPLETED) - saved = self.manager.load() - assert saved is not None - assert saved.current_level == "1.9" - assert "1.9" not in saved.levels_complete - assert saved.level_attempts["1.9"] == 1 - - -@pytest.fixture(params=["bash", "fish"]) -def home_walk_shell(request: pytest.FixtureRequest, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> _HomeWalkShell: - shell = request.param - if not shutil.which(shell): - pytest.skip(f"{shell} not installed") - - home = tmp_path / "home" / "student" - home.mkdir(parents=True) - workspace = tmp_path / "workspace" - workspace.mkdir() - scratch = tmp_path / "shell-scratch" - scratch.mkdir() - config = tmp_path / "config" - monkeypatch.setenv("XDG_CONFIG_HOME", str(config)) - manager = StateManager() - state = GameState( - username="student", - workspace=workspace, - current_level="1.9", - start_time=datetime.now(), - level_started_at={"1.9": datetime.now()}, - ) - manager.save(state) - env = { - key: value - for key, value in os.environ.items() - if not key.startswith(("SHELLGAME_", "BASH_FUNC_")) and key not in {"BASH_ENV", "ENV", "CDPATH", "OLDPWD"} - } - env.update( - HOME=str(home), - XDG_CONFIG_HOME=str(config), - XDG_DATA_HOME=str(tmp_path / "data"), - XDG_CACHE_HOME=str(tmp_path / "cache"), - TMPDIR=str(scratch), - PYTHONPATH=str(Path(__file__).resolve().parents[1] / "src"), - SHELLGAME_WRAPPER="1", - SHELLGAME_AUTO_STARTED="1", - SHELLGAME_LEVEL="1.9", - SHELLGAME_WORKSPACE=str(workspace), - ) - renderer = get_fish_integration if shell == "fish" else get_bash_integration - integration = tmp_path / f"integration.{shell}" - integration.write_text(renderer(f"{shlex.quote(sys.executable)} -m shellgame"), encoding="utf-8") - return _HomeWalkShell(shell, integration, home, state, manager, env) - - -@pytest.mark.parametrize("target", ["'~'", "'$HOME'"]) -def test_quoted_home_names_do_not_expand(home_walk_shell: _HomeWalkShell, target: str) -> None: - result = home_walk_shell.probe_move(f"cd {target}") - - assert "CD_STATUS=1" in result.stdout - assert str(home_walk_shell.state.workspace) in result.stdout.splitlines() - assert not home_walk_shell.markers.exists(MarkerManager.LEVEL1_9_CD_WALK_PROGRESS) - home_walk_shell.assert_incomplete() - - -@pytest.mark.parametrize("name", ["~", "$HOME"]) -def test_quoted_home_names_can_enter_literal_directories(home_walk_shell: _HomeWalkShell, name: str) -> None: - literal = home_walk_shell.state.workspace / name - literal.mkdir() - - result = home_walk_shell.probe_move(f"cd {shlex.quote(name)}") - - assert "CD_STATUS=0" in result.stdout - assert str(literal) in result.stdout.splitlines() - home_walk_shell.assert_incomplete() - - -@pytest.mark.parametrize("command", ["cd", "cd ~", "cd $HOME", 'cd "$HOME"']) -def test_expanded_home_shortcuts_are_blocked(home_walk_shell: _HomeWalkShell, command: str) -> None: - result = home_walk_shell.probe_move(command) - - assert "CD_STATUS=1" in result.stdout - assert str(home_walk_shell.state.workspace) in result.stdout.splitlines() - assert "shellgame reset" in result.stdout + result.stderr - home_walk_shell.assert_incomplete() - - -def test_single_segment_symlink_jump_cannot_complete_walk(home_walk_shell: _HomeWalkShell) -> None: - workspace = home_walk_shell.state.workspace - (workspace / "shortcut").symlink_to(home_walk_shell.home, target_is_directory=True) - walk_to_workspace = "; ".join(["cd /", *(f"cd {shlex.quote(part)}" for part in workspace.parts[1:])]) - status = "$status" if home_walk_shell.shell == "fish" else "$?" - - result = home_walk_shell.run( - f"{walk_to_workspace}; cd shortcut; printf 'CD_STATUS=%s\\n' {status}; command pwd -P; shellgame submit" - ) - - assert "CD_STATUS=0" in result.stdout - assert str(home_walk_shell.home) in result.stdout.splitlines() - home_walk_shell.assert_incomplete() - - -def test_real_root_to_home_walk_completes(home_walk_shell: _HomeWalkShell) -> None: - walk_home = "; ".join(["cd /", *(f"cd {shlex.quote(part)}" for part in home_walk_shell.home.parts[1:])]) - - home_walk_shell.run(f"{walk_home}; shellgame submit") - - assert home_walk_shell.markers.exists(MarkerManager.LEVEL1_9_CD_WALK_COMPLETED) - saved = home_walk_shell.manager.load() - assert saved is not None - assert "1.9" in saved.levels_complete - assert saved.current_level == "1.10" diff --git a/tests/test_section1_ls_help_progression.py b/tests/test_section1_ls_help_progression.py deleted file mode 100644 index e4a3085..0000000 --- a/tests/test_section1_ls_help_progression.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Focused checks for the early ``ls`` and help progression.""" - -from pathlib import Path - -from shellgame.levels.sections.section1 import LsLevel - - -def test_level1_2_moves_from_plain_ls_to_discovering_long_listing() -> None: - level = LsLevel() - instructions = level.instructions - - assert instructions.index("`ls`") < instructions.index("`ls --help`") - assert "`man ls`" in instructions - assert "dlouhý (podrobný) výpis" in instructions - assert "ls -F" not in instructions - - -def test_level1_2_hints_reveal_exact_commands_late() -> None: - hints = LsLevel().hints - - assert all(command not in " ".join(hints[:2]) for command in ("ls --help", "man ls", "ls -l")) - assert "ls --help" in hints[-2] - assert "man ls" in hints[-2] - assert "ls -l" in hints[-1] - assert all("ls -F" not in hint for hint in hints) - - -def test_section1_intro_lists_plain_ls_before_help_tools() -> None: - intro_path = Path(__file__).parents[1] / "src/shellgame/levels/content/section1_intro.md" - intro = intro_path.read_text(encoding="utf-8") - - assert intro.index("`ls`") < intro.index("`<příkaz> --help`") < intro.index("`man <příkaz>`") - assert "ls -F" not in intro diff --git a/tests/test_section1_maze.py b/tests/test_section1_maze.py deleted file mode 100644 index 8e90d21..0000000 --- a/tests/test_section1_maze.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Regression tests for Section 1 maze content.""" - -from pathlib import Path - -from shellgame.levels.sections.section1 import MazeLevel, resolve_maze_instruction - - -def test_level1_7_maze_has_first_step(tmp_path: Path) -> None: - level = MazeLevel() - level.prepare(tmp_path) - - start_dir = tmp_path / "level-1" / "maze" / "entry" - assert start_dir.is_dir() - instruction = start_dir / "GO_TO_DIR_hall" - assert instruction.is_file() - - destination = resolve_maze_instruction(instruction.name, start_dir) - assert destination is not None - assert destination.is_dir() - assert destination == tmp_path / "level-1" / "maze" / "entry" / "hall" - - -def test_level1_7_deep_step_go_up_2_then_go_to_catacombs_is_valid(tmp_path: Path) -> None: - level = MazeLevel() - level.prepare(tmp_path) - - maze_root = tmp_path / "level-1" / "maze" - deep_step = maze_root / "entry" / "hall" / "nexus" / "passages" / "tunnel" / "cavern" / "depths" - assert deep_step.is_dir() - - instruction = deep_step / "GO_UP_2_THEN_GO_TO_catacombs" - assert instruction.is_file() - - destination = resolve_maze_instruction(instruction.name, deep_step) - assert destination == maze_root / "entry" / "hall" / "nexus" / "passages" / "tunnel" / "catacombs" - assert destination.is_dir() - - -def test_level1_7_main_route_provides_sustained_navigation_practice(tmp_path: Path) -> None: - level = MazeLevel() - level.prepare(tmp_path) - - current = tmp_path / "level-1" / "maze" / "entry" - final = tmp_path / "level-1" / level._SANCTUARY - visited = {current} - instructions: list[str] = [] - - while current != final: - go_files = sorted(path for path in current.glob("GO_*") if path.is_file()) - assert len(go_files) == 1, f"Expected one instruction in {current}, found {go_files}" - instruction = go_files[0].name - instructions.append(instruction) - destination = resolve_maze_instruction(instruction, current) - assert destination is not None and destination.is_dir() - assert destination not in visited, f"Main route loops at {destination}" - visited.add(destination) - current = destination - - assert len(instructions) >= 16 - assert instructions.count("GO_UP_2_THEN_GO_TO_catacombs") == 1 - assert instructions.count("GO_UP_2_THEN_GO_TO_labyrinth") == 1 - assert (final / "VICTORY.marker").is_file() - - -def test_every_maze_instruction_resolves_to_an_existing_directory(tmp_path: Path) -> None: - """A GO_ file that cannot be followed is a soft-lock dressed up as a puzzle.""" - level = MazeLevel() - level.prepare(tmp_path) - - maze_root = tmp_path / "level-1" / "maze" - broken: list[str] = [] - - for instruction in maze_root.rglob("GO_*"): - if not instruction.is_file(): - continue - destination = resolve_maze_instruction(instruction.name, instruction.parent) - if destination is None or not destination.is_dir(): - broken.append(f"{instruction.relative_to(maze_root)} -> {destination}") - - assert not broken, "Unfollowable maze instructions:\n " + "\n ".join(broken) - - -def test_maze_instruction_files_are_not_blank(tmp_path: Path) -> None: - """Empty GO_ files make `cat` look like the level is broken.""" - level = MazeLevel() - level.prepare(tmp_path) - - blank = [ - str(path.relative_to(tmp_path)) - for path in (tmp_path / "level-1" / "maze").rglob("GO_*") - if path.is_file() and not path.read_text(encoding="utf-8").strip() - ] - assert not blank, f"Blank instruction files: {blank}" diff --git a/tests/test_section1_maze_branching.py b/tests/test_section1_maze_branching.py deleted file mode 100644 index 9222d9d..0000000 --- a/tests/test_section1_maze_branching.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Tests that the Section 1 maze feels like a maze (has branches/decoys).""" - -from pathlib import Path - -from shellgame.levels.sections.section1 import MazeLevel, resolve_maze_instruction - - -def test_level1_7_maze_has_branches_loops_and_many_decoys(tmp_path: Path) -> None: - level = MazeLevel() - level.prepare(tmp_path) - - maze_root = tmp_path / "level-1" / "maze" - start = maze_root / "entry" - assert start.is_dir() - - # Branching at the start: decoys along with the right path - assert (start / "hall").is_dir() - assert (start / "dungeon").is_dir() - assert (start / "courtyard").is_dir() - - # Local loop: the instruction inside side_door sends the player back to hall. - hall = start / "hall" - side_door = hall / "side_door" - assert side_door.is_dir() - assert (side_door / "GO_UP_1").is_file() - assert resolve_maze_instruction("GO_UP_1", side_door) == hall - - # The route contains enough wrong branches that learners must keep reading - # instructions instead of guessing a single linear sequence. - traps = list(maze_root.rglob("YOU_ARE_NOT_SUPPOSED_TO_BE_HERE")) - assert len(traps) >= 12 - - nexus = hall / "nexus" - assert (nexus / "passages").is_dir() - assert (nexus / "sanctum").is_dir() - assert (nexus / "rotunda").is_dir() - assert (nexus / "archives").is_dir() diff --git a/tests/test_section1_maze_markers.py b/tests/test_section1_maze_markers.py deleted file mode 100644 index 5d3bd3e..0000000 --- a/tests/test_section1_maze_markers.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Regression tests for Section 1 maze marker invariants.""" - -from __future__ import annotations - -from dataclasses import dataclass -from pathlib import Path -from typing import Any - -from shellgame.levels.sections.section1 import MazeLevel - - -@dataclass -class _State: - username: str - workspace: Path - - -def test_level1_7_each_dir_has_go_xor_warning(tmp_path: Path, monkeypatch: Any) -> None: - """Each maze directory must contain either a GO_ file or a warning marker, but never both.""" - - workspace = tmp_path / "ws" - workspace.mkdir() - - level = MazeLevel() - level.prepare(workspace) - - maze_root = workspace / "level-1" / "maze" - assert maze_root.is_dir() - - for d in maze_root.rglob("*"): - if not d.is_dir(): - continue - - entries = [p.name for p in d.iterdir()] - has_go = any(name.startswith("GO_") for name in entries) - has_warn = "YOU_ARE_NOT_SUPPOSED_TO_BE_HERE" in entries - has_victory = "VICTORY.marker" in entries - - if has_victory: - assert not has_go and not has_warn, f"Victory dir {d} must not have GO or warning markers" - else: - # XOR: exactly one of them is present - assert has_go != has_warn, ( - f"Dir {d} violates marker rule; has_go={has_go}, has_warn={has_warn}, entries={entries}" - ) - - -def test_level1_7_trap_file_includes_reset_instruction(tmp_path: Path) -> None: - workspace = tmp_path / "ws" - workspace.mkdir() - - level = MazeLevel() - level.prepare(workspace) - - maze_root = workspace / "level-1" / "maze" - trap_file = maze_root / "entry" / "dungeon" / "YOU_ARE_NOT_SUPPOSED_TO_BE_HERE" - assert trap_file.is_file() - content = trap_file.read_text(encoding="utf-8") - assert "shellgame reset" in content - assert "maze/entry" in content diff --git a/tests/test_section6_level6_7_alias.py b/tests/test_section6_level6_7_alias.py deleted file mode 100644 index 151374c..0000000 --- a/tests/test_section6_level6_7_alias.py +++ /dev/null @@ -1,96 +0,0 @@ -import shutil -import subprocess -from datetime import datetime -from pathlib import Path - -import pytest - -from shellgame.levels.sections.section6 import ( - FileOrganizerChallengeLevel, - ShellgameAliasLevel, - section, -) -from shellgame.state.manager import GameState - - -def _state(workspace: Path) -> GameState: - return GameState( - username="tester", - workspace=workspace, - current_level="6.7", - start_time=datetime.now(), - ) - - -def test_level6_7_teaches_alias_for_both_shells_and_its_scope() -> None: - level = ShellgameAliasLevel() - - assert level.id == "6.7" - assert "alias sg='shellgame'" in level.instructions - assert "alias sg shellgame" in level.instructions - assert "type sg" in level.instructions - assert "jen v aktuálním herním shellu" in level.instructions - assert "po jeho ukončení zmizí" in level.instructions - assert "sg submit alias-ready" in level.instructions - assert "místo dlouhého `shellgame` používat kratší `sg`" in level.instructions - assert "použití `sg` je součást tohoto cvičení" in level.instructions - - -@pytest.mark.parametrize( - ("shell", "command"), - [ - ( - "bash", - "function shellgame(){ printf '%s\\n' \"$*\"; }; " - "shopt -s expand_aliases; alias sg='shellgame'; eval 'sg submit alias-ready'", - ), - ( - "fish", - "function shellgame; string join ' ' $argv; end; alias sg shellgame; sg submit alias-ready", - ), - ], -) -def test_level6_7_displayed_alias_commands_work(shell: str, command: str) -> None: - executable = shutil.which(shell) - if executable is None: - pytest.skip(f"{shell} is not installed") - - result = subprocess.run( # noqa: S603 - [executable, "--no-config" if shell == "fish" else "--noprofile", "-c", command], - capture_output=True, - text=True, - check=True, - timeout=10, - ) - - assert result.stdout.strip() == "submit alias-ready" - - -def test_level6_7_completion_requires_explicit_alias_ready_answer(tmp_path: Path) -> None: - level = ShellgameAliasLevel() - level.prepare(tmp_path) - state = _state(tmp_path) - - success, message = level.validate("alias-ready", state) - assert success is True - assert message == level.success_message - - missing, missing_message = level.validate(None, state) - assert missing is False - assert "sg submit alias-ready" in missing_message - - wrong, _ = level.validate("ready", state) - assert wrong is False - - -def test_section6_ends_on_the_challenge_not_the_alias_aside() -> None: - """Every section closes on its summary/challenge. - - The alias level is a shell-ergonomics aside, so it was moved ahead of the - challenge. Only the last level may claim the section is finished. - """ - levels = {level.id: level for level in section.levels} - - assert section.levels[-1].id == FileOrganizerChallengeLevel.id - assert "Dokončili jste Sekci 6" in levels[FileOrganizerChallengeLevel.id].success_message - assert "Dokončili jste Sekci 6" not in levels[ShellgameAliasLevel.id].success_message diff --git a/tests/test_section7_bash_recipes.py b/tests/test_section7_bash_recipes.py deleted file mode 100644 index 6ac481c..0000000 --- a/tests/test_section7_bash_recipes.py +++ /dev/null @@ -1,108 +0,0 @@ -"""Replay the displayed Bash-only recipes from both supported game shells. - -Section 10 teaches wildcard *construction*, so the task text deliberately shows -only the shape of the command (`cp VZOR short_data/`). The ready-made pattern -lives in the final hint, which is the surface this module replays: a recipe the -game prints must actually work, fresh and after `reset()`, in bash and in fish. -""" - -import os -import re -import shutil -import subprocess -from datetime import datetime -from pathlib import Path - -import pytest - -from shellgame.levels.base import Level -from shellgame.levels.sections.section7 import ( - CharacterClassWildcardCopyLevel, - QuestionMarkWildcardCopyLevel, - RangeWildcardCopyLevel, - SectionIntro, - StarWildcardCopyLevel, - WildcardsChallengeLevel, -) -from shellgame.state.manager import GameState - -BASH_LEVELS = (QuestionMarkWildcardCopyLevel, CharacterClassWildcardCopyLevel, RangeWildcardCopyLevel) - - -@pytest.mark.parametrize("level_type", BASH_LEVELS, ids=lambda level: level.id) -@pytest.mark.parametrize("shell", ["bash", "fish"]) -def test_displayed_recipe_completes_fresh_and_reset_levels( - level_type: type[Level], shell: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - if not shutil.which(shell) or not shutil.which("bash"): - pytest.skip(f"{shell} and bash are required") - workspace = tmp_path / "workspace with spaces" - workspace.mkdir() - home = tmp_path / "home" - home.mkdir() - env = { - key: value - for key, value in os.environ.items() - if not key.startswith(("SHELLGAME_", "BASH_FUNC_")) - and key not in {"BASH_ENV", "ENV", "BASHOPTS", "SHELLOPTS", "CDPATH", "OLDPWD"} - } - env.update( - HOME=str(home), - XDG_CONFIG_HOME=str(home / "config"), - XDG_DATA_HOME=str(home / "data"), - XDG_CACHE_HOME=str(home / "cache"), - ) - level = level_type() - assert "vyžaduje **Bash**" in level.instructions - text = level.hints[-1] - prefix = "cp " if shell == "bash" else "bash -c " - commands = re.findall(rf"`({re.escape(prefix)}[^`]+)`", text) - assert len(commands) == 1 - flags = ["--no-config"] if shell == "fish" else [] - state = GameState(username="tester", workspace=workspace, current_level=level.id, start_time=datetime.now()) - - for prepare in (level.prepare, level.reset): - prepare(workspace) - start = level.get_start_directory(workspace) - assert start is not None - monkeypatch.chdir(start) - assert not level.validate(None, state)[0] - result = subprocess.run( - [shell, *flags, "-c", commands[0]], - cwd=start, - env=env, - capture_output=True, - text=True, - check=True, - timeout=10, - ) - assert result.stderr == "" - success, message = level.validate(None, state) - assert success, message - - -@pytest.mark.parametrize("level_type", BASH_LEVELS, ids=lambda level: level.id) -def test_task_text_shows_the_shape_not_the_finished_pattern(level_type: type[Level]) -> None: - """Constructing the glob is the whole lesson, so the task must not hand it over. - - Without this, section 10 degrades into transcription: the learner copies the - pattern out of the instructions and never has to reason about `?`, `[...]` - or a character class at all. - """ - level = level_type() - solution_step = level.solution.steps[0] - pattern = str(solution_step.command).split()[1] - - assert "VZOR" in level.instructions, f"{level.id}: task text must show the placeholder shape" - assert pattern not in level.instructions, f"{level.id}: task text reveals the finished pattern {pattern!r}" - assert pattern in level.hints[-1], f"{level.id}: final hint must still carry the finished pattern" - - -def test_intro_scopes_bash_requirement_and_preserves_game_shell() -> None: - instructions = SectionIntro().instructions - assert "7.2–7.4 vyžadují Bash" in instructions - assert "bash -c" in instructions - assert "shellgame submit" in instructions - assert "stiskněte Enter" in instructions - for level in (StarWildcardCopyLevel(), WildcardsChallengeLevel()): - assert "vyžaduje **Bash**" not in level.instructions diff --git a/tests/test_section8_directory_permission_levels.py b/tests/test_section8_directory_permission_levels.py deleted file mode 100644 index f3f8125..0000000 --- a/tests/test_section8_directory_permission_levels.py +++ /dev/null @@ -1,111 +0,0 @@ -"""Behavior and pedagogy checks for interactive directory-permission levels.""" - -from __future__ import annotations - -import os -import stat -import subprocess -from datetime import datetime -from pathlib import Path - -import pytest - -from shellgame.levels.sections.section8 import ( - DirectoryTraversePermissionLevel, - DirectoryWritePermissionLevel, - PermissionsChallengeLevel, -) -from shellgame.state.manager import GameState - - -def _state(workspace: Path, level_id: str) -> GameState: - return GameState(username="tester", workspace=workspace, current_level=level_id, start_time=datetime.now()) - - -def _mode(path: Path) -> int: - return stat.S_IMODE(path.stat().st_mode) - - -def test_file_permission_challenge_is_no_longer_presented_as_section_finale() -> None: - level = PermissionsChallengeLevel() - - assert "Souhrn Sekce 7" not in level.title - assert "Dokončili jste Sekci 7" not in level.success_message - - -def test_directory_write_level_starts_locked_and_solution_explains_parent_directory(tmp_path: Path) -> None: - level = DirectoryWritePermissionLevel() - level.prepare(tmp_path) - root = level.section_path(tmp_path) - start = level.get_start_directory(tmp_path) - - assert start == root / "directory-write" - assert _mode(start / "locked") == 0o550 - assert "Předpověď a pozorování" in level.instructions - assert "cp source.txt locked/" in level.instructions - assert "cp locked/report.txt recovered.txt" in level.instructions - - state = _state(tmp_path, level.id) - assert not level.validate("adresář", state)[0] - assert level.solution is not None - level.solution.perform(level, state) - assert level.validate("adresář", state)[0] - assert _mode(start / "locked") == 0o750 - - -@pytest.mark.skipif(os.geteuid() == 0, reason="root bypasses ordinary directory permission checks") -def test_directory_write_level_produces_the_failure_it_teaches(tmp_path: Path) -> None: - level = DirectoryWritePermissionLevel() - level.prepare(tmp_path) - start = level.get_start_directory(tmp_path) - assert start is not None - - result = subprocess.run( - ["cp", "source.txt", "locked/"], - cwd=start, - capture_output=True, - text=True, - check=False, - timeout=10, - ) - - assert result.returncode != 0 - assert not (start / "locked/source.txt").exists() - - -def test_directory_traverse_level_restores_only_owner_execute_and_exact_location(tmp_path: Path) -> None: - level = DirectoryTraversePermissionLevel() - level.prepare(tmp_path) - root = level.section_path(tmp_path) - parent = root / "directory-traverse/parent" - - assert level.get_start_directory(tmp_path) == parent / "child" - assert _mode(parent) == 0o755 - assert "Předpověď a pozorování" in level.instructions - assert "chmod a-x .." in level.instructions - assert "chmod u+x .." in level.instructions - - state = _state(tmp_path, level.id) - assert level.solution is not None - level.solution.perform(level, state) - assert level.validate("x", state)[0] - assert Path.cwd() == parent - assert _mode(parent) == 0o744 - - -@pytest.mark.skipif(os.geteuid() == 0, reason="root bypasses ordinary directory permission checks") -def test_directory_traverse_level_produces_the_failure_it_teaches(tmp_path: Path) -> None: - level = DirectoryTraversePermissionLevel() - level.prepare(tmp_path) - child = level.get_start_directory(tmp_path) - assert child is not None - parent = child.parent - original = Path.cwd() - try: - os.chdir(child) - parent.chmod(0o644) - with pytest.raises(PermissionError): - os.chdir("..") - finally: - parent.chmod(0o755) - os.chdir(original) diff --git a/tests/test_section9_interactive_levels.py b/tests/test_section9_interactive_levels.py deleted file mode 100644 index 0a520d1..0000000 --- a/tests/test_section9_interactive_levels.py +++ /dev/null @@ -1,115 +0,0 @@ -"""Focused tests for the interactive terminal lessons in section 8.""" - -from __future__ import annotations - -import shutil -import subprocess -from datetime import datetime -from pathlib import Path - -import pytest - -from shellgame.levels.sections.section9 import ( - CommandStatusLevel, - EchoCreateFileLevel, - InteractiveCatInputLevel, - section, -) -from shellgame.state.manager import GameState - - -def _state(workspace: Path, level_id: str) -> GameState: - return GameState( - username="tester", - workspace=workspace, - current_level=level_id, - start_time=datetime.now(), - ) - - -def _run_shell( - shell: str, - command: str, - cwd: Path, - *, - input_text: str | None = None, -) -> None: - executable = shutil.which(shell) - if executable is None: - pytest.skip(f"{shell} is not installed") - _ = subprocess.run( # noqa: S603 - [executable, "-c", command], - cwd=cwd, - input=input_text, - text=True, - check=True, - capture_output=True, - ) - - -def test_level_9_4_quotes_real_output_filename_and_multiword_text(tmp_path: Path) -> None: - level = EchoCreateFileLevel() - level.prepare(tmp_path) - start = level.get_start_directory(tmp_path) - assert start is not None - - _run_shell("bash", 'echo "Ahoj svete" > "muj pozdrav.txt"', start) - - output = start / "muj pozdrav.txt" - assert level.id == "9.4" - assert output.read_text(encoding="utf-8") == "Ahoj svete\n" - assert level.validate("muj pozdrav.txt", _state(tmp_path, level.id))[0] - assert '"Ahoj svete"' in level.instructions - assert '"muj pozdrav.txt"' in level.instructions - - -def test_level_9_10_reads_stdin_until_eof_and_requires_exact_content(tmp_path: Path) -> None: - level = InteractiveCatInputLevel() - level.prepare(tmp_path) - start = level.get_start_directory(tmp_path) - assert start is not None - state = _state(tmp_path, level.id) - - _run_shell( - "bash", - "cat > poznamka.txt", - start, - input_text="První řádek\nDruhý řádek\n", - ) - - assert level.id == "9.10" - assert level.validate("poznamka.txt", state)[0] - assert "Ctrl+D" in level.instructions and "EOF" in level.instructions - assert "Ctrl+C" in level.instructions and "interrupt" in level.instructions - - _ = (start / "poznamka.txt").write_text("První řádek\nDruhý řádek\nnavíc\n", encoding="utf-8") - assert not level.validate("poznamka.txt", state)[0] - - -@pytest.mark.parametrize("shell", ["bash", "fish"]) -def test_level_9_11_status_chains_work_in_supported_shells(shell: str, tmp_path: Path) -> None: - level = CommandStatusLevel() - level.prepare(tmp_path) - start = level.get_start_directory(tmp_path) - assert start is not None - - command = """\ -true && echo "stav: uspech" > status.txt -false || echo "stav: neuspech" >> status.txt -""" - _run_shell(shell, command, start) - - assert level.id == "9.11" - assert (start / "status.txt").read_text(encoding="utf-8") == "stav: uspech\nstav: neuspech\n" - assert level.validate("status.txt", _state(tmp_path, level.id))[0] - - -def test_level_9_11_is_the_section_completion() -> None: - levels = {level.id: level for level in section.levels} - - assert section.levels[-1].id == "9.11" - assert "souhrn" not in levels["9.9"].title.lower() - assert "Dokončili jste Sekci 9" not in levels["9.9"].success_message - assert "Dokončili jste Sekci 9" in levels["9.11"].success_message - assert levels["9.10"].solution is not None - assert levels["9.11"].solution is not None diff --git a/tests/test_session_mechanics.py b/tests/test_session_mechanics.py deleted file mode 100644 index 89e1519..0000000 --- a/tests/test_session_mechanics.py +++ /dev/null @@ -1,322 +0,0 @@ -"""Robustness tests for :class:`GameSession` game mechanics. - -These cover the contracts a player relies on and that used to be silently -broken: - -* the level's own ``success_message`` reaches the player (not a generic one) -* success statistics (time / hints / failed attempts) are reported -* advancing performs exactly one atomic save and never loses progress -* finishing the last level is a real terminal state -* bonus levels are skippable, core levels are not -* a broken level fixture cannot destroy the player's progress -""" - -from __future__ import annotations - -import shutil -from datetime import datetime, timedelta -from pathlib import Path - -import pytest - -from shellgame.core.session import GameSession -from shellgame.levels.base import Level -from shellgame.levels.registry import LevelRegistry -from shellgame.levels.sections.section5 import FindCriticalCodeInLogLevel -from shellgame.levels.sections.section6 import BackupImportantFileLevel -from shellgame.state.manager import GameState -from tests.fakes import ( - _FakeConsole, - _FakeDisplay, - _FakeLevel, - _FakeRegistry, - _FakeShell, - _FakeStateManager, - _FakeWorkspace, -) - - -def _make_state(tmp_path: Path, current_level: str = "1.1") -> GameState: - return GameState( - username="student", - current_level=current_level, - workspace=tmp_path / "ws", - start_time=datetime.now(), - ) - - -def _make_session( - tmp_path: Path, levels: list[_FakeLevel], state: GameState | None -) -> tuple[GameSession, _FakeDisplay, _FakeStateManager]: - display = _FakeDisplay() - state_manager = _FakeStateManager(state) - workspace_root = tmp_path / "ws" - workspace_root.mkdir(parents=True, exist_ok=True) - session = GameSession( - console=_FakeConsole(), - display=display, # type: ignore[arg-type] - state_manager=state_manager, # type: ignore[arg-type] - level_registry=_FakeRegistry(levels), # type: ignore[arg-type] - teleport_notice=lambda _destination: None, - shell_client=_FakeShell(), # type: ignore[arg-type] - workspace_factory=lambda _workspace: _FakeWorkspace(workspace_root), # type: ignore[arg-type] - ) - return session, display, state_manager - - -@pytest.mark.parametrize( - ("level_type", "relative_file"), - [ - (BackupImportantFileLevel, "copying/dulezite.txt"), - (FindCriticalCodeInLogLevel, "logs/server.log"), - ], -) -def test_reset_rescues_player_inside_directory_replacing_a_fixture_file( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, level_type: type[Level], relative_file: str -) -> None: - level = level_type() - state = _make_state(tmp_path, current_level=level.id) - state.workspace.mkdir() - state.level_hints_used[level.id] = 2 - level.prepare(state.workspace) - damaged = level.section_path(state.workspace) / relative_file - damaged.unlink() - damaged.mkdir() - monkeypatch.chdir(damaged) - registry = LevelRegistry() - registry.register(level) - display = _FakeDisplay() - state_manager = _FakeStateManager(state) - shell = _FakeShell() - session = GameSession( - console=_FakeConsole(), - display=display, # type: ignore[arg-type] - state_manager=state_manager, # type: ignore[arg-type] - level_registry=registry, - teleport_notice=lambda _destination: None, - shell_client=shell, # type: ignore[arg-type] - workspace_factory=lambda workspace: _FakeWorkspace(workspace), # type: ignore[arg-type] - ) - - session.reset() - - assert damaged.is_file() - assert Path.cwd() == level.get_start_directory(state.workspace) - assert shell.exported["SHELLGAME_LEVEL"] == level.id - assert shell.exported["SHELLGAME_WORKSPACE"] == str(state.workspace) - assert state.level_hints_used[level.id] == 2 - assert state_manager.saves == 0 - assert "show_reset" in display.names() - assert level.solution is not None - level.solution.perform(level, state) - session.submit(level.solution.answer) - assert level.id in state.levels_complete - - -class TestSuccessFeedback: - def test_level_success_message_reaches_the_player(self, tmp_path: Path) -> None: - """A hand-written per-level message must not be replaced by a generic one.""" - levels = [ - _FakeLevel("1.1", result=(True, "Skvěle! Našli jste adresář delta.")), - _FakeLevel("1.2"), - ] - state = _make_state(tmp_path) - session, display, _ = _make_session(tmp_path, levels, state) - - session.submit("delta") - - args, _ = display.call("show_success") - assert args[0] == "Skvěle! Našli jste adresář delta." - - def test_success_reports_time_hints_and_failed_attempts(self, tmp_path: Path) -> None: - levels = [_FakeLevel("1.1"), _FakeLevel("1.2")] - state = _make_state(tmp_path) - state.level_started_at["1.1"] = datetime.now() - timedelta(seconds=42) - state.level_hints_used["1.1"] = 2 - state.level_attempts["1.1"] = 3 - session, display, _ = _make_session(tmp_path, levels, state) - - session.submit("delta") - - _, kwargs = display.call("show_success") - assert kwargs["hints_used"] == 2 - assert kwargs["attempts"] == 3 - assert kwargs["time_sec"] is not None - assert kwargs["time_sec"] >= 42 - - def test_failure_shows_the_validators_message(self, tmp_path: Path) -> None: - levels = [_FakeLevel("1.1", result=(False, "To je soubor, ne adresář."))] - state = _make_state(tmp_path) - session, display, _ = _make_session(tmp_path, levels, state) - - session.submit("data.txt") - - args, _ = display.call("show_failure") - assert args[0] == "To je soubor, ne adresář." - assert "show_success" not in display.names() - - -class TestAdvancing: - def test_advance_saves_state_exactly_once(self, tmp_path: Path) -> None: - levels = [_FakeLevel("1.1"), _FakeLevel("1.2")] - state = _make_state(tmp_path) - session, _, state_manager = _make_session(tmp_path, levels, state) - - session.submit("x") - - assert state_manager.saves == 1 - assert state.current_level == "1.2" - - def test_progress_is_kept_when_the_next_level_fails_to_prepare(self, tmp_path: Path) -> None: - """A broken fixture must never cost the player their completed level.""" - levels = [ - _FakeLevel("1.1"), - _FakeLevel("1.2", prepare_error=OSError("disk on fire")), - ] - state = _make_state(tmp_path) - session, display, state_manager = _make_session(tmp_path, levels, state) - - session.submit("x") - - assert state_manager.state is not None - assert state_manager.state.current_level == "1.2" - assert "1.1" in state.levels_complete - assert "show_level_setup_error" in display.names() - - def test_intro_levels_do_not_show_a_success_panel(self, tmp_path: Path) -> None: - intro = _FakeLevel("1.0") - intro.is_intro = True - levels = [intro, _FakeLevel("1.1")] - state = _make_state(tmp_path, current_level="1.0") - session, display, _ = _make_session(tmp_path, levels, state) - - session.submit(None) - - assert "show_success" not in display.names() - - -class TestTerminalState: - def test_finishing_the_last_level_marks_the_game_complete(self, tmp_path: Path) -> None: - levels = [_FakeLevel("11.9")] - state = _make_state(tmp_path, current_level="11.9") - session, display, _ = _make_session(tmp_path, levels, state) - - session.submit("x") - - assert state.completed_at is not None - assert "show_game_complete" in display.names() - - @pytest.mark.parametrize("action", ["submit", "skip", "hint", "show_current_level"]) - def test_actions_after_completion_are_inert(self, tmp_path: Path, action: str) -> None: - """A finished game must not re-run the last level or crash.""" - levels = [_FakeLevel("11.9")] - state = _make_state(tmp_path, current_level="11.9") - state.completed_at = datetime.now() - session, display, state_manager = _make_session(tmp_path, levels, state) - - if action == "submit": - session.submit("x") - elif action == "skip": - session.skip() - elif action == "hint": - session.hint(repeat=False) - else: - session.show_current_level() - - assert "show_game_complete" in display.names() - assert state_manager.saves == 0 - - -class TestSkip: - def test_optional_level_can_be_skipped(self, tmp_path: Path) -> None: - levels = [_FakeLevel("1.11", optional=True), _FakeLevel("2.0")] - state = _make_state(tmp_path, current_level="1.11") - session, display, _ = _make_session(tmp_path, levels, state) - - session.skip() - - assert state.current_level == "2.0" - assert "show_skipped" in display.names() - - def test_extension_level_can_be_skipped(self, tmp_path: Path) -> None: - levels = [_FakeLevel("1.8", extension=True), _FakeLevel("1.9")] - state = _make_state(tmp_path, current_level="1.8") - session, display, _ = _make_session(tmp_path, levels, state) - - session.skip() - - assert state.current_level == "1.9" - assert "show_skipped" in display.names() - - def test_core_level_cannot_be_skipped(self, tmp_path: Path) -> None: - """Skipping must never be a way around required material.""" - levels = [_FakeLevel("1.1"), _FakeLevel("1.2")] - state = _make_state(tmp_path) - session, display, state_manager = _make_session(tmp_path, levels, state) - - session.skip() - - assert state.current_level == "1.1" - assert "show_not_skippable" in display.names() - assert state_manager.saves == 0 - - def test_skipping_does_not_count_as_a_completion(self, tmp_path: Path) -> None: - levels = [_FakeLevel("1.11", optional=True), _FakeLevel("2.0")] - state = _make_state(tmp_path, current_level="1.11") - session, display, _ = _make_session(tmp_path, levels, state) - - session.skip() - - assert "1.11" not in state.levels_complete - assert "show_success" not in display.names() - - -class TestNotInitialized: - @pytest.mark.parametrize("action", ["submit", "skip", "reset", "hint"]) - def test_actions_without_state_are_safe(self, tmp_path: Path, action: str) -> None: - session, display, _ = _make_session(tmp_path, [_FakeLevel("1.1")], None) - - if action == "submit": - session.submit("x") - elif action == "skip": - session.skip() - elif action == "reset": - session.reset() - else: - session.hint(repeat=False) - - assert "show_not_initialized" in display.names() - - -class TestWorkspaceRestore: - """A cleared `/tmp` must rebuild from every gameplay command, not just `shellgame`.""" - - @pytest.mark.parametrize("action", ["submit", "skip", "reset", "hint"]) - def test_missing_workspace_is_restored(self, tmp_path: Path, action: str) -> None: - levels = [_FakeLevel("1.1", optional=True), _FakeLevel("1.2")] - state = _make_state(tmp_path) - session, display, _ = _make_session(tmp_path, levels, state) - - shutil.rmtree(state.workspace) - assert not state.workspace.exists() - - if action == "submit": - session.submit("x") - elif action == "skip": - session.skip() - elif action == "reset": - session.reset() - else: - session.hint(repeat=False) - - assert state.workspace.is_dir() - assert "show_workspace_restored" in display.names() - - def test_present_workspace_is_not_reported_as_restored(self, tmp_path: Path) -> None: - levels = [_FakeLevel("1.1"), _FakeLevel("1.2")] - state = _make_state(tmp_path) - session, display, _ = _make_session(tmp_path, levels, state) - - session.submit("x") - - assert "show_workspace_restored" not in display.names() diff --git a/tests/test_shell_client.py b/tests/test_shell_client.py deleted file mode 100644 index 6b13e74..0000000 --- a/tests/test_shell_client.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Tests for the versioned shell protocol encoder.""" - -import base64 -from io import StringIO - -import pytest - -from shellgame.shell.client import PROTOCOL_PREFIX, ShellClient - - -def _decode_directive(line: str) -> tuple[str, list[str]]: - payload = line.removeprefix(PROTOCOL_PREFIX).split() - assert payload[0] == "v1" - return payload[1], [base64.b64decode(arg).decode() for arg in payload[2:]] - - -def test_cd_encodes_path_without_shell_parsing() -> None: - stream = StringIO() - client = ShellClient(stream) - - client.cd("/tmp/path with spaces;$(ignored)") - - assert _decode_directive(stream.getvalue().strip()) == ( - "cd", - ["/tmp/path with spaces;$(ignored)"], - ) - - -def test_export_encodes_name_and_value_separately() -> None: - stream = StringIO() - client = ShellClient(stream) - - client.export("SHELLGAME_LEVEL", "value with spaces") - - assert _decode_directive(stream.getvalue().strip()) == ( - "export", - ["SHELLGAME_LEVEL", "value with spaces"], - ) - - -def test_export_rejects_invalid_variable_name() -> None: - with pytest.raises(ValueError, match="Invalid environment variable"): - ShellClient(StringIO()).export("BAD-NAME", "value") - - -def test_protocol_rejects_unknown_command() -> None: - with pytest.raises(ValueError, match="Unsupported shell protocol"): - ShellClient(StringIO())._emit("eval", "echo unsafe") diff --git a/tests/test_shell_integration_linting.py b/tests/test_shell_integration_linting.py deleted file mode 100644 index 1a06a62..0000000 --- a/tests/test_shell_integration_linting.py +++ /dev/null @@ -1,71 +0,0 @@ -import shutil -import subprocess - -import pytest - -from shellgame.cli.subshell import get_bash_integration, get_fish_integration - - -def _binary_path_for_tests() -> str: - # We don't actually execute `shellgame` in these tests; we just validate - # that the generated shell integration scripts are syntactically valid. - return "shellgame" - - -def _run(cmd: list[str], *, input_text: str | None = None) -> subprocess.CompletedProcess[str]: - return subprocess.run( - cmd, - input=input_text, - text=True, - capture_output=True, - check=False, - ) - - -@pytest.mark.optional_shell_lint -def test_bash_integration_passes_shellcheck() -> None: - shellcheck = shutil.which("shellcheck") - if not shellcheck: - pytest.skip("shellcheck not installed") - - script = get_bash_integration(_binary_path_for_tests(), devmode=False) - # -s bash: treat as bash - # -: read from stdin - proc = _run([shellcheck, "-S", "error", "-s", "bash", "-"], input_text=script) - assert proc.returncode == 0, proc.stdout + proc.stderr - - -@pytest.mark.optional_shell_lint -def test_bash_integration_devmode_passes_shellcheck() -> None: - shellcheck = shutil.which("shellcheck") - if not shellcheck: - pytest.skip("shellcheck not installed") - - script = get_bash_integration(_binary_path_for_tests(), devmode=True) - proc = _run([shellcheck, "-S", "error", "-s", "bash", "-"], input_text=script) - assert proc.returncode == 0, proc.stdout + proc.stderr - - -@pytest.mark.optional_shell_lint -def test_fish_integration_has_valid_syntax() -> None: - fish = shutil.which("fish") - if not fish: - pytest.skip("fish not installed") - - script = get_fish_integration(_binary_path_for_tests(), devmode=False) - - # `fish -n` parses the script for syntax errors without executing it. - # We pipe the script via stdin. - proc = _run([fish, "-n"], input_text=script) - assert proc.returncode == 0, proc.stdout + proc.stderr - - -@pytest.mark.optional_shell_lint -def test_fish_integration_devmode_has_valid_syntax() -> None: - fish = shutil.which("fish") - if not fish: - pytest.skip("fish not installed") - - script = get_fish_integration(_binary_path_for_tests(), devmode=True) - proc = _run([fish, "-n"], input_text=script) - assert proc.returncode == 0, proc.stdout + proc.stderr diff --git a/tests/test_shell_integration_parity.py b/tests/test_shell_integration_parity.py deleted file mode 100644 index 183e89b..0000000 --- a/tests/test_shell_integration_parity.py +++ /dev/null @@ -1,551 +0,0 @@ -"""Parity tests ensuring bash and fish integrations behave identically. - -These tests verify that both shell integrations: -1. Handle exit codes consistently -2. Propagate arguments correctly -3. Handle special characters safely -4. Execute the pwd wrapper correctly -5. Handle the protocol marker in various positions - -Tests are skipped if the respective shell is not installed. -""" - -import base64 -import os -import shlex -import shutil -import subprocess -import sys -from pathlib import Path -from typing import Any - -import pytest - -from shellgame.cli.hooks import ( - generate_bash_cd_hooks, - generate_fish_cd_hooks, - get_cd_hooked_levels, -) -from shellgame.cli.subshell import get_bash_integration, get_fish_integration - - -def _protocol_line(command: str, *args: str) -> str: - encoded = [base64.b64encode(arg.encode()).decode() for arg in args] - return "__SHELLGAME_EXEC__" + " ".join(("v1", command, *encoded)) - - -def _create_mock_game(tmp_path: Path, code: str) -> str: - """Create a mock Python script and return a shell-escaped command to run it.""" - game_script = tmp_path / "mock_game.py" - game_script.write_text(code, encoding="utf-8") - return " ".join(shlex.quote(p) for p in [sys.executable, str(game_script)]) - - -def _run_bash( - integration_path: Path, command: str, env: dict[str, str] | None = None -) -> subprocess.CompletedProcess[str]: - """Run a command in bash with the integration loaded.""" - return subprocess.run( - ["bash", "--noprofile", "--norc", "-c", f"source {integration_path}; {command}"], - check=False, - capture_output=True, - text=True, - env={**os.environ, **env} if env else None, - ) - - -def _run_fish( - integration_path: Path, command: str, env: dict[str, str] | None = None -) -> subprocess.CompletedProcess[str]: - """Run a command in fish with the integration loaded.""" - return subprocess.run( - ["fish", "--no-config", "-c", f"source {integration_path}; {command}"], - check=False, - capture_output=True, - text=True, - env={**os.environ, **env} if env else None, - ) - - -class TestExitCodePropagation: - """Test that exit codes from the game are propagated through the wrapper.""" - - @pytest.mark.skipif(not shutil.which("bash"), reason="bash not installed") - def test_bash_propagates_exit_code_zero(self, tmp_path: Path) -> None: - mock_code = "import sys; sys.exit(0)" - binary_cmd = _create_mock_game(tmp_path, mock_code) - integration = get_bash_integration(binary_cmd, devmode=False) - int_file = tmp_path / "integration.bash" - int_file.write_text(integration) - - result = _run_bash(int_file, 'shellgame; echo "exit:$?"') - assert "exit:0" in result.stdout - - @pytest.mark.skipif(not shutil.which("bash"), reason="bash not installed") - def test_bash_propagates_exit_code_nonzero(self, tmp_path: Path) -> None: - mock_code = "import sys; sys.exit(42)" - binary_cmd = _create_mock_game(tmp_path, mock_code) - integration = get_bash_integration(binary_cmd, devmode=False) - int_file = tmp_path / "integration.bash" - int_file.write_text(integration) - - result = _run_bash(int_file, 'shellgame; echo "exit:$?"') - assert "exit:42" in result.stdout - - @pytest.mark.skipif(not shutil.which("fish"), reason="fish not installed") - def test_fish_propagates_exit_code_zero(self, tmp_path: Path) -> None: - mock_code = "import sys; sys.exit(0)" - binary_cmd = _create_mock_game(tmp_path, mock_code) - integration = get_fish_integration(binary_cmd, devmode=False) - int_file = tmp_path / "integration.fish" - int_file.write_text(integration) - - result = _run_fish(int_file, 'shellgame; echo "exit:$status"') - assert "exit:0" in result.stdout - - @pytest.mark.skipif(not shutil.which("fish"), reason="fish not installed") - def test_fish_propagates_exit_code_nonzero(self, tmp_path: Path) -> None: - mock_code = "import sys; sys.exit(42)" - binary_cmd = _create_mock_game(tmp_path, mock_code) - integration = get_fish_integration(binary_cmd, devmode=False) - int_file = tmp_path / "integration.fish" - int_file.write_text(integration) - - result = _run_fish(int_file, 'shellgame; echo "exit:$status"') - assert "exit:42" in result.stdout - - -class TestArgumentPropagation: - """Test that arguments are passed correctly to the game.""" - - @pytest.mark.skipif(not shutil.which("bash"), reason="bash not installed") - def test_bash_passes_simple_arguments(self, tmp_path: Path) -> None: - mock_code = "import sys; print('ARGS:', sys.argv[1:])" - binary_cmd = _create_mock_game(tmp_path, mock_code) - integration = get_bash_integration(binary_cmd, devmode=False) - int_file = tmp_path / "integration.bash" - int_file.write_text(integration) - - result = _run_bash(int_file, "shellgame submit answer123") - assert "['submit', 'answer123']" in result.stdout - - @pytest.mark.skipif(not shutil.which("fish"), reason="fish not installed") - def test_fish_passes_simple_arguments(self, tmp_path: Path) -> None: - mock_code = "import sys; print('ARGS:', sys.argv[1:])" - binary_cmd = _create_mock_game(tmp_path, mock_code) - integration = get_fish_integration(binary_cmd, devmode=False) - int_file = tmp_path / "integration.fish" - int_file.write_text(integration) - - result = _run_fish(int_file, "shellgame submit answer123") - assert "['submit', 'answer123']" in result.stdout - - @pytest.mark.skipif(not shutil.which("bash"), reason="bash not installed") - def test_bash_handles_arguments_with_spaces(self, tmp_path: Path) -> None: - mock_code = "import sys; print('ARGS:', sys.argv[1:])" - binary_cmd = _create_mock_game(tmp_path, mock_code) - integration = get_bash_integration(binary_cmd, devmode=False) - int_file = tmp_path / "integration.bash" - int_file.write_text(integration) - - result = _run_bash(int_file, 'shellgame submit "hello world"') - assert "['submit', 'hello world']" in result.stdout - - @pytest.mark.skipif(not shutil.which("fish"), reason="fish not installed") - def test_fish_handles_arguments_with_spaces(self, tmp_path: Path) -> None: - mock_code = "import sys; print('ARGS:', sys.argv[1:])" - binary_cmd = _create_mock_game(tmp_path, mock_code) - integration = get_fish_integration(binary_cmd, devmode=False) - int_file = tmp_path / "integration.fish" - int_file.write_text(integration) - - result = _run_fish(int_file, 'shellgame submit "hello world"') - assert "['submit', 'hello world']" in result.stdout - - -class TestProtocolEdgeCases: - """Test protocol handling edge cases.""" - - @pytest.mark.skipif(not shutil.which("bash"), reason="bash not installed") - def test_bash_handles_protocol_marker_in_middle_of_line(self, tmp_path: Path) -> None: - # Protocol marker should ONLY be recognized at start of line - mock_code = """ -import sys -print("text before __SHELLGAME_EXEC__echo SHOULD_NOT_RUN", file=sys.stderr) -""" - binary_cmd = _create_mock_game(tmp_path, mock_code) - integration = get_bash_integration(binary_cmd, devmode=False) - int_file = tmp_path / "integration.bash" - int_file.write_text(integration) - - result = _run_bash(int_file, "shellgame") - assert "SHOULD_NOT_RUN" not in result.stdout - assert "__SHELLGAME_EXEC__" in result.stderr - - @pytest.mark.skipif(not shutil.which("fish"), reason="fish not installed") - def test_fish_handles_protocol_marker_in_middle_of_line(self, tmp_path: Path) -> None: - mock_code = """ -import sys -print("text before __SHELLGAME_EXEC__echo SHOULD_NOT_RUN", file=sys.stderr) -""" - binary_cmd = _create_mock_game(tmp_path, mock_code) - integration = get_fish_integration(binary_cmd, devmode=False) - int_file = tmp_path / "integration.fish" - int_file.write_text(integration) - - result = _run_fish(int_file, "shellgame") - assert "SHOULD_NOT_RUN" not in result.stdout - assert "__SHELLGAME_EXEC__" in result.stderr - - @pytest.mark.skipif(not shutil.which("bash"), reason="bash not installed") - def test_bash_handles_multiple_protocol_commands(self, tmp_path: Path) -> None: - first = _protocol_line("echo", "FIRST") - second = _protocol_line("echo", "SECOND") - mock_code = f""" -import sys -print({first!r}, file=sys.stderr) -print({second!r}, file=sys.stderr) -""" - binary_cmd = _create_mock_game(tmp_path, mock_code) - integration = get_bash_integration(binary_cmd, devmode=False) - int_file = tmp_path / "integration.bash" - int_file.write_text(integration) - - result = _run_bash(int_file, "shellgame") - assert "FIRST" in result.stdout - assert "SECOND" in result.stdout - - @pytest.mark.skipif(not shutil.which("fish"), reason="fish not installed") - def test_fish_handles_multiple_protocol_commands(self, tmp_path: Path) -> None: - first = _protocol_line("echo", "FIRST") - second = _protocol_line("echo", "SECOND") - mock_code = f""" -import sys -print({first!r}, file=sys.stderr) -print({second!r}, file=sys.stderr) -""" - binary_cmd = _create_mock_game(tmp_path, mock_code) - integration = get_fish_integration(binary_cmd, devmode=False) - int_file = tmp_path / "integration.fish" - int_file.write_text(integration) - - result = _run_fish(int_file, "shellgame") - assert "FIRST" in result.stdout - assert "SECOND" in result.stdout - - @pytest.mark.skipif(not shutil.which("bash"), reason="bash not installed") - def test_bash_handles_empty_protocol_command(self, tmp_path: Path) -> None: - mock_code = """ -import sys -print("__SHELLGAME_EXEC__", file=sys.stderr) -print("normal output", file=sys.stderr) -""" - binary_cmd = _create_mock_game(tmp_path, mock_code) - integration = get_bash_integration(binary_cmd, devmode=False) - int_file = tmp_path / "integration.bash" - int_file.write_text(integration) - - result = _run_bash(int_file, "shellgame") - # Should not crash - assert result.returncode == 0 - assert "normal output" in result.stderr - - @pytest.mark.skipif(not shutil.which("fish"), reason="fish not installed") - def test_fish_handles_empty_protocol_command(self, tmp_path: Path) -> None: - mock_code = """ -import sys -print("__SHELLGAME_EXEC__", file=sys.stderr) -print("normal output", file=sys.stderr) -""" - binary_cmd = _create_mock_game(tmp_path, mock_code) - integration = get_fish_integration(binary_cmd, devmode=False) - int_file = tmp_path / "integration.fish" - int_file.write_text(integration) - - result = _run_fish(int_file, "shellgame") - assert result.returncode == 0 - assert "normal output" in result.stderr - - -class TestPwdWrapper: - """Test that pwd wrapper records usage correctly.""" - - @pytest.fixture - def user_dir(self, tmp_path: Path) -> Path: - """Create a fake user directory for testing.""" - user = os.environ.get("USER", "test") - user_dir = tmp_path / f"shellgame-{user}" - user_dir.mkdir() - return user_dir - - @pytest.mark.skipif(not shutil.which("bash"), reason="bash not installed") - def test_bash_pwd_records_usage(self, tmp_path: Path, user_dir: Path, monkeypatch: Any) -> None: - mock_code = "print('done')" - binary_cmd = _create_mock_game(tmp_path, mock_code) - integration = get_bash_integration(binary_cmd, devmode=False) - int_file = tmp_path / "integration.bash" - int_file.write_text(integration) - - marker_file = user_dir / ".pwd_used" - assert not marker_file.exists() - - result = _run_bash(int_file, "pwd", env={"SHELLGAME_WORKSPACE": str(user_dir)}) - assert result.returncode == 0 - assert marker_file.exists() - - @pytest.mark.skipif(not shutil.which("fish"), reason="fish not installed") - def test_fish_pwd_records_usage(self, tmp_path: Path, user_dir: Path) -> None: - mock_code = "print('done')" - binary_cmd = _create_mock_game(tmp_path, mock_code) - int_file = tmp_path / "integration.fish" - int_file.write_text(get_fish_integration(binary_cmd, devmode=False)) - - marker_file = user_dir / ".pwd_used" - assert not marker_file.exists() - - result = _run_fish(int_file, "pwd", env={"SHELLGAME_WORKSPACE": str(user_dir)}) - assert result.returncode == 0 - assert marker_file.exists() - - def test_templates_never_guess_the_workspace_path(self) -> None: - """The workspace must come from SHELLGAME_WORKSPACE only. - - A `$USER`-derived fallback would let a stale or foreign directory - collect evidence markers, so it must not exist in either template. - """ - binary_cmd = "shellgame" - for integration in ( - get_bash_integration(binary_cmd, devmode=False), - get_fish_integration(binary_cmd, devmode=False), - ): - assert "$USER" not in integration - assert "/tmp/shellgame-" not in integration - - @pytest.mark.skipif(not shutil.which("bash"), reason="bash not installed") - def test_bash_pwd_is_safe_without_workspace(self, tmp_path: Path) -> None: - """`pwd` must still work (and create nothing) when no workspace is set.""" - binary_cmd = _create_mock_game(tmp_path, "print('done')") - int_file = tmp_path / "integration.bash" - int_file.write_text(get_bash_integration(binary_cmd, devmode=False)) - - result = _run_bash(int_file, "pwd", env={"SHELLGAME_WORKSPACE": ""}) - assert result.returncode == 0 - assert not list(tmp_path.glob("**/.pwd_used")) - - @pytest.mark.skipif(not shutil.which("fish"), reason="fish not installed") - def test_fish_pwd_is_safe_without_workspace(self, tmp_path: Path) -> None: - """`pwd` must still work (and create nothing) when no workspace is set.""" - binary_cmd = _create_mock_game(tmp_path, "print('done')") - int_file = tmp_path / "integration.fish" - int_file.write_text(get_fish_integration(binary_cmd, devmode=False)) - - result = _run_fish(int_file, "pwd", env={"SHELLGAME_WORKSPACE": ""}) - assert result.returncode == 0 - assert not list(tmp_path.glob("**/.pwd_used")) - - -class TestCdHookConsistency: - """Test that cd hooks are generated consistently for both shells.""" - - def test_bash_and_fish_hooks_cover_same_levels(self) -> None: - """Ensure both generators produce hooks for the same levels.""" - hooked_levels = get_cd_hooked_levels() - bash_hooks = generate_bash_cd_hooks() - fish_hooks = generate_fish_cd_hooks() - - bash_patterns = next( - line for line in bash_hooks.splitlines() if line.strip().startswith('"') and line.strip().endswith(")") - ) - fish_patterns = next(line for line in fish_hooks.splitlines() if line.strip().startswith('case "')) - - for level_id in hooked_levels: - assert f'"{level_id}"' in bash_patterns, f"Missing bash hook for {level_id}" - assert f'"{level_id}"' in fish_patterns, f"Missing fish hook for {level_id}" - - def test_hooks_dispatch_by_shellgame_level_env(self) -> None: - """Both shells should dispatch cd based on SHELLGAME_LEVEL environment variable.""" - bash_hooks = generate_bash_cd_hooks() - fish_hooks = generate_fish_cd_hooks() - - # Bash uses case statement - assert 'case "$SHELLGAME_LEVEL"' in bash_hooks - # Fish uses switch - assert 'switch "$SHELLGAME_LEVEL"' in fish_hooks - - -@pytest.mark.skip(reason="Requires shellgame binary in PATH") -class TestProtocolCdBypassesHooks: - """Test that protocol cd commands bypass user-facing cd hooks. - - This is critical: when the game teleports the user between levels, - the protocol cd must use builtin cd, not the hooked cd function. - Otherwise, hooks like level 1.9 (which reject absolute paths) would - break level transitions. - """ - - @pytest.mark.skipif(not shutil.which("bash"), reason="bash not installed") - def test_bash_protocol_cd_bypasses_hook_that_rejects_absolute_paths(self, tmp_path: Path) -> None: - """Protocol cd should work even when SHELLGAME_LEVEL is set to a hook that rejects absolute paths.""" - # Create a target directory - target_dir = tmp_path / "target" - target_dir.mkdir() - - # Mock game that sets SHELLGAME_LEVEL=1.9 (which rejects absolute paths) - # and then tries to cd to an absolute path via protocol - export_level = _protocol_line("export", "SHELLGAME_LEVEL", "1.9") - change_directory = _protocol_line("cd", str(target_dir)) - mock_code = f""" -import sys -print({export_level!r}, file=sys.stderr) -print({change_directory!r}, file=sys.stderr) -""" - binary_cmd = _create_mock_game(tmp_path, mock_code) - integration = get_bash_integration(binary_cmd, devmode=False) - int_file = tmp_path / "integration.bash" - int_file.write_text(integration) - - int_file.write_text(integration) - - result = _run_bash(int_file, "shellgame; pwd") - # The protocol cd should have succeeded, putting us in target_dir - assert str(target_dir) in result.stdout - - @pytest.mark.skipif(not shutil.which("fish"), reason="fish not installed") - def test_fish_protocol_cd_bypasses_hook_that_rejects_absolute_paths(self, tmp_path: Path) -> None: - """Protocol cd should work even when SHELLGAME_LEVEL is set to a hook that rejects absolute paths.""" - target_dir = tmp_path / "target" - target_dir.mkdir() - - export_level = _protocol_line("export", "SHELLGAME_LEVEL", "1.9") - change_directory = _protocol_line("cd", str(target_dir)) - mock_code = f""" -import sys -print({export_level!r}, file=sys.stderr) -print({change_directory!r}, file=sys.stderr) -""" - binary_cmd = _create_mock_game(tmp_path, mock_code) - integration = get_fish_integration(binary_cmd, devmode=False) - int_file = tmp_path / "integration.fish" - - int_file.write_text(integration) - - result = _run_fish(int_file, "shellgame; pwd") - assert str(target_dir) in result.stdout - - @pytest.mark.skipif(not shutil.which("bash"), reason="bash not installed") - def test_bash_user_cd_still_uses_hook(self, tmp_path: Path) -> None: - """Direct user cd should still go through the hook (not bypass it).""" - target_dir = tmp_path / "target" - target_dir.mkdir() - - # Mock game that just sets the level - export_level = _protocol_line("export", "SHELLGAME_LEVEL", "1.9") - mock_code = f""" -import sys -print({export_level!r}, file=sys.stderr) -""" - binary_cmd = _create_mock_game(tmp_path, mock_code) - integration = get_bash_integration(binary_cmd, devmode=False) - - int_file = tmp_path / "integration.bash" - int_file.write_text(integration) - - # After shellgame sets SHELLGAME_LEVEL=1.9, a direct user cd to absolute path should fail - result = _run_bash(int_file, f"shellgame; cd {target_dir} 2>&1; echo exit:$?") - # The hook should reject this (exit code 1) - assert "exit:1" in result.stdout - # And show the rejection message - assert "1.9" in result.stdout - - @pytest.mark.skipif(not shutil.which("fish"), reason="fish not installed") - def test_fish_user_cd_still_uses_hook(self, tmp_path: Path) -> None: - """Direct user cd should still go through the hook (not bypass it).""" - target_dir = tmp_path / "target" - target_dir.mkdir() - - export_level = _protocol_line("export", "SHELLGAME_LEVEL", "1.9") - mock_code = f""" -import sys -print({export_level!r}, file=sys.stderr) -""" - binary_cmd = _create_mock_game(tmp_path, mock_code) - integration = get_fish_integration(binary_cmd, devmode=False) - - int_file = tmp_path / "integration.fish" - int_file.write_text(integration) - - result = _run_fish(int_file, f"shellgame; cd {target_dir} 2>&1; echo exit:$status") - assert "exit:1" in result.stdout - assert "1.9" in result.stdout - - -class TestAutocompleteParity: - """Test autocomplete behavior for both bash and fish.""" - - @pytest.mark.skipif(not shutil.which("bash"), reason="bash not installed") - def test_bash_completes_commands_and_options(self, tmp_path: Path) -> None: - binary_cmd = _create_mock_game(tmp_path, "import sys; sys.exit(0)") - integration = get_bash_integration(binary_cmd, devmode=False) - int_file = tmp_path / "integration.bash" - int_file.write_text(integration) - - # Test root completion - result = _run_bash( - int_file, - 'COMP_WORDS=(shellgame ""); COMP_CWORD=1; _shellgame_completions; echo "root:${COMPREPLY[*]}"', - ) - assert "hint" in result.stdout - assert "submit" in result.stdout - assert "repeat" in result.stdout - assert "show" in result.stdout - - # Test hint options - result = _run_bash( - int_file, - 'COMP_WORDS=(shellgame hint "-"); COMP_CWORD=2; _shellgame_completions; echo "hint:${COMPREPLY[*]}"', - ) - assert "--repeat" in result.stdout - assert "-r" in result.stdout - - # Test repeat options - result = _run_bash( - int_file, - 'COMP_WORDS=(shellgame repeat "--"); COMP_CWORD=2; _shellgame_completions; echo "repeat:${COMPREPLY[*]}"', - ) - assert "--section" in result.stdout - assert "--level" in result.stdout - - # Test alias sg - result = _run_bash( - int_file, - 'COMP_WORDS=(sg hint "-"); COMP_CWORD=2; _shellgame_completions; echo "sg:${COMPREPLY[*]}"', - ) - assert "--repeat" in result.stdout - - @pytest.mark.skipif(not shutil.which("fish"), reason="fish not installed") - def test_fish_completes_commands_and_options(self, tmp_path: Path) -> None: - binary_cmd = _create_mock_game(tmp_path, "import sys; sys.exit(0)") - integration = get_fish_integration(binary_cmd, devmode=False) - int_file = tmp_path / "integration.fish" - int_file.write_text(integration) - - # Test root completion - result = _run_fish(int_file, 'complete -C "shellgame "') - assert "hint" in result.stdout - assert "submit" in result.stdout - assert "repeat" in result.stdout - assert "show" in result.stdout - - # Test hint options - result = _run_fish(int_file, 'complete -C "shellgame hint -"') - assert "--repeat" in result.stdout - assert "-r" in result.stdout - - # Test repeat options - result = _run_fish(int_file, 'complete -C "shellgame repeat --"') - assert "--section" in result.stdout - assert "--level" in result.stdout - - # Test alias sg - result = _run_fish(int_file, 'complete -C "sg hint -"') - assert "--repeat" in result.stdout diff --git a/tests/test_shell_integration_runtime.py b/tests/test_shell_integration_runtime.py deleted file mode 100644 index 93e5aef..0000000 --- a/tests/test_shell_integration_runtime.py +++ /dev/null @@ -1,373 +0,0 @@ -"""Runtime tests for shell integration scripts. - -These tests actually execute the generated shell scripts using `bash` and `fish` -(if available) to verify that the protocol handling (stderr filtering and -command execution) works as expected in a real shell environment. -""" - -import base64 -import os -import shlex -import shutil -import subprocess -import sys -from pathlib import Path - -import pytest - -from shellgame.cli.subshell import ( - _create_fd_hook_script, - get_bash_integration, - get_fish_integration, -) - - -def _protocol_line(command: str, *args: str) -> str: - encoded = [base64.b64encode(arg.encode()).decode() for arg in args] - return "__SHELLGAME_EXEC__" + " ".join(("v1", command, *encoded)) - - -# Helper to create a mock game binary (python script) -def create_mock_game(tmp_path: Path, content: str) -> str: - game_script = tmp_path / "mock_game.py" - game_script.write_text(content, encoding="utf-8") - # Return a command string that runs this script - return f"{sys.executable} {game_script}" - - -def test_fd_hook_launcher_preserves_redirected_descriptors(tmp_path: Path) -> None: - report = tmp_path / "fd-report.txt" - game_script = tmp_path / "fd_probe.py" - game_script.write_text( - """ -import os -import sys -from pathlib import Path - -if sys.argv[1:] == ["fd-hook"]: - Path(os.environ["FD_REPORT"]).write_text( - os.readlink("/proc/self/fd/1") + "\\n" + os.readlink("/proc/self/fd/2"), - encoding="utf-8", - ) -""", - encoding="utf-8", - ) - launcher = _create_fd_hook_script([sys.executable, str(game_script)]) - - try: - with Path("/dev/null").open("wb") as dev_null: - result = subprocess.run( - [launcher], - check=False, - stdout=dev_null, - stderr=dev_null, - env={**os.environ, "FD_REPORT": str(report)}, - ) - finally: - Path(launcher).unlink() - - assert result.returncode == 0 - assert report.read_text(encoding="utf-8").splitlines() == [ - "/dev/null", - "/dev/null", - ] - - -@pytest.mark.skipif(not shutil.which("bash"), reason="bash not installed") -def test_bash_integration_runtime_protocol(tmp_path: Path) -> None: - # 1. Create a mock game that emits a protocol command via stderr - # The protocol command will be 'echo "PROTOCOL_WORKED"' - protocol_echo = _protocol_line("echo", "PROTOCOL_WORKED") - mock_game_code = f""" -import sys -print({protocol_echo!r}, file=sys.stderr) -print("normal stderr", file=sys.stderr) -""" - binary_cmd = create_mock_game(tmp_path, mock_game_code) - - # 2. Generate Bash integration script - # We need to be careful about how binary_path is passed. - # get_bash_integration expects a string that will be put into `SHELLGAME_BINARY_ARR=($binary_path)` - # So we should quote the parts. - quoted_binary = " ".join(shlex.quote(p) for p in binary_cmd.split()) - - integration_script = get_bash_integration(quoted_binary, devmode=False) - integration_file = tmp_path / "integration.bash" - integration_file.write_text(integration_script, encoding="utf-8") - - # 3. Run bash - # Source the integration, then run `shellgame` - # We capture stdout to verify 'PROTOCOL_WORKED' appears - cmd = ["bash", "--noprofile", "--norc", "-c", f"source {integration_file}; shellgame"] - - result = subprocess.run(cmd, check=False, capture_output=True, text=True) - - assert result.returncode == 0 - assert "PROTOCOL_WORKED" in result.stdout - assert "normal stderr" in result.stderr - # The protocol line itself should NOT be in stderr - assert "__SHELLGAME_EXEC__" not in result.stderr - - -@pytest.mark.skipif(not shutil.which("fish"), reason="fish not installed") -def test_fish_integration_runtime_protocol(tmp_path: Path) -> None: - # 1. Create a mock game - protocol_echo = _protocol_line("echo", "PROTOCOL_WORKED") - mock_game_code = f""" -import sys -print({protocol_echo!r}, file=sys.stderr) -print("normal stderr", file=sys.stderr) -""" - binary_cmd = create_mock_game(tmp_path, mock_game_code) - - # 2. Generate Fish integration script - # Fish integration uses `eval $SHELLGAME_BINARY $argv` - quoted_binary = " ".join(shlex.quote(p) for p in binary_cmd.split()) - - integration_script = get_fish_integration(quoted_binary, devmode=False) - integration_file = tmp_path / "integration.fish" - integration_file.write_text(integration_script, encoding="utf-8") - - # 3. Run fish - cmd = ["fish", "--no-config", "-c", f"source {integration_file}; shellgame"] - - result = subprocess.run(cmd, check=False, capture_output=True, text=True) - - assert result.returncode == 0 - assert "PROTOCOL_WORKED" in result.stdout - assert "normal stderr" in result.stderr - assert "__SHELLGAME_EXEC__" not in result.stderr - - -@pytest.mark.skipif(not shutil.which("bash"), reason="bash not installed") -def test_bash_protocol_preserves_cd_path_with_spaces(tmp_path: Path) -> None: - target = tmp_path / "directory with spaces" - target.mkdir() - protocol_cd = _protocol_line("cd", str(target)) - mock_game_code = f""" -import sys -print({protocol_cd!r}, file=sys.stderr) -""" - binary_cmd = create_mock_game(tmp_path, mock_game_code) - quoted_binary = " ".join(shlex.quote(p) for p in binary_cmd.split()) - integration = get_bash_integration(quoted_binary, devmode=False) - integration_file = tmp_path / "integration.bash" - integration_file.write_text(integration, encoding="utf-8") - - result = subprocess.run( - ["bash", "--noprofile", "--norc", "-c", f"source {integration_file}; pwd"], - check=False, - capture_output=True, - text=True, - ) - - assert result.returncode == 0 - assert str(target) in result.stdout - - -@pytest.mark.skipif(not shutil.which("bash"), reason="bash not installed") -def test_bash_protocol_exit_closes_wrapper(tmp_path: Path) -> None: - protocol_exit = _protocol_line("exit") - mock_game_code = f""" -import sys -if sys.argv[1:] == ["exit"]: - print({protocol_exit!r}, file=sys.stderr) -""" - binary_cmd = create_mock_game(tmp_path, mock_game_code) - quoted_binary = " ".join(shlex.quote(p) for p in binary_cmd.split()) - integration = get_bash_integration(quoted_binary, devmode=False) - integration_file = tmp_path / "integration.bash" - integration_file.write_text(integration, encoding="utf-8") - shell_tmp = tmp_path / "shell-tmp" - shell_tmp.mkdir() - - result = subprocess.run( - [ - "bash", - "--noprofile", - "--norc", - "-c", - f"source {integration_file}; shellgame exit; echo SHOULD_NOT_RUN", - ], - check=False, - capture_output=True, - text=True, - env={**os.environ, "TMPDIR": str(shell_tmp)}, - ) - - assert result.returncode == 0 - assert "SHOULD_NOT_RUN" not in result.stdout - assert list(shell_tmp.iterdir()) == [] - - -@pytest.mark.skipif(not shutil.which("fish"), reason="fish not installed") -def test_fish_protocol_exit_closes_wrapper_without_leaking_temp_file(tmp_path: Path) -> None: - protocol_exit = _protocol_line("exit") - mock_game_code = f""" -import sys -if sys.argv[1:] == ["exit"]: - print({protocol_exit!r}, file=sys.stderr) -""" - binary_cmd = create_mock_game(tmp_path, mock_game_code) - quoted_binary = " ".join(shlex.quote(p) for p in binary_cmd.split()) - integration = get_fish_integration(quoted_binary, devmode=False) - integration_file = tmp_path / "integration.fish" - integration_file.write_text(integration, encoding="utf-8") - shell_tmp = tmp_path / "shell-tmp" - shell_tmp.mkdir() - - result = subprocess.run( - [ - "fish", - "--no-config", - "-c", - f"source {integration_file}; shellgame exit; echo SHOULD_NOT_RUN", - ], - check=False, - capture_output=True, - text=True, - env={**os.environ, "TMPDIR": str(shell_tmp)}, - ) - - assert result.returncode == 0 - assert "SHOULD_NOT_RUN" not in result.stdout - assert list(shell_tmp.iterdir()) == [] - - -@pytest.mark.skipif(not shutil.which("fish"), reason="fish not installed") -def test_fish_integration_injection_vulnerability(tmp_path: Path) -> None: - # Mock game that just prints args - mock_game_code = """ -import sys -print(f"ARGS: {sys.argv[1:]}") -""" - binary_cmd = create_mock_game(tmp_path, mock_game_code) - - # Generate Fish integration script - quoted_binary = " ".join(shlex.quote(p) for p in binary_cmd.split()) - - integration_script = get_fish_integration(quoted_binary, devmode=False) - integration_file = tmp_path / "integration.fish" - integration_file.write_text(integration_script, encoding="utf-8") - - # Attempt injection: pass a command separator and another command - # We want to see if 'echo PWNED' gets executed by the shell - injection_arg = "; echo PWNED" - - cmd = ["fish", "--no-config", "-c", f"source {integration_file}; shellgame '{injection_arg}'"] - - result = subprocess.run(cmd, check=False, capture_output=True, text=True) - - # If fixed, 'PWNED' should NOT be printed by the shell echo, - # but it SHOULD be printed by the python script as an argument. - assert "PWNED" not in result.stdout or "ARGS: ['; echo PWNED']" in result.stdout - - -@pytest.mark.skipif(not shutil.which("bash"), reason="bash not installed") -def test_bash_integration_injection_vulnerability(tmp_path: Path) -> None: - # Mock game that just prints args - mock_game_code = """ -import sys -print(f"ARGS: {sys.argv[1:]}") -""" - binary_cmd = create_mock_game(tmp_path, mock_game_code) - - # Generate Bash integration script - quoted_binary = " ".join(shlex.quote(p) for p in binary_cmd.split()) - - integration_script = get_bash_integration(quoted_binary, devmode=False) - integration_file = tmp_path / "integration.bash" - integration_file.write_text(integration_script, encoding="utf-8") - - # Attempt injection: pass a command separator and another command - # We want to see if 'echo PWNED' gets executed by the shell - injection_arg = "; echo PWNED" - - cmd = [ - "bash", - "--noprofile", - "--norc", - "-c", - f"source {integration_file}; shellgame '{injection_arg}'", - ] - - result = subprocess.run(cmd, check=False, capture_output=True, text=True) - - # If fixed, 'PWNED' should NOT be printed by the shell echo, - # but it SHOULD be printed by the python script as an argument. - assert "PWNED" not in result.stdout or "ARGS: ['; echo PWNED']" in result.stdout - - -@pytest.mark.skipif(not shutil.which("bash"), reason="bash not installed") -def test_bash_relocates_from_deleted_cwd_before_invoking_python(tmp_path: Path) -> None: - workspace = tmp_path / "workspace" - workspace.mkdir() - sub = workspace / "sub" - sub.mkdir() - - mock_game_code = """ -import os -import sys -print(f"CWD: {os.getcwd()}") -""" - binary_cmd = create_mock_game(tmp_path, mock_game_code) - quoted_binary = " ".join(shlex.quote(p) for p in binary_cmd.split()) - integration = get_bash_integration(quoted_binary, devmode=False) - integration_file = tmp_path / "integration.bash" - integration_file.write_text(integration, encoding="utf-8") - - cmd = [ - "bash", - "--noprofile", - "--norc", - "-c", - f"source {integration_file}; cd {sub}; rm -rf {sub}; shellgame check", - ] - - result = subprocess.run( - cmd, - check=False, - capture_output=True, - text=True, - env={**os.environ, "SHELLGAME_WORKSPACE": str(workspace)}, - ) - - assert result.returncode == 0 - assert f"CWD: {workspace.resolve()}" in result.stdout - - -@pytest.mark.skipif(not shutil.which("fish"), reason="fish not installed") -def test_fish_relocates_from_deleted_cwd_before_invoking_python(tmp_path: Path) -> None: - workspace = tmp_path / "workspace" - workspace.mkdir() - sub = workspace / "sub" - sub.mkdir() - - mock_game_code = """ -import os -import sys -print(f"CWD: {os.getcwd()}") -""" - binary_cmd = create_mock_game(tmp_path, mock_game_code) - quoted_binary = " ".join(shlex.quote(p) for p in binary_cmd.split()) - integration = get_fish_integration(quoted_binary, devmode=False) - integration_file = tmp_path / "integration.fish" - integration_file.write_text(integration, encoding="utf-8") - - cmd = [ - "fish", - "--no-config", - "-c", - f"source {integration_file}; cd {sub}; rm -rf {sub}; shellgame check", - ] - - result = subprocess.run( - cmd, - check=False, - capture_output=True, - text=True, - env={**os.environ, "SHELLGAME_WORKSPACE": str(workspace)}, - ) - - assert result.returncode == 0 - assert f"CWD: {workspace.resolve()}" in result.stdout diff --git a/tests/test_solutions.py b/tests/test_solutions.py deleted file mode 100644 index d2b9b5a..0000000 --- a/tests/test_solutions.py +++ /dev/null @@ -1,162 +0,0 @@ -"""Prove that every level declaring a reference solution can actually be finished. - -The rest of the suite checks that levels reject wrong answers. These tests check -the other direction, which is the one that silently breaks: a requirement path -the fixture never creates, an evidence marker no hook writes, or a `cd_policy` -that rejects the command its own instructions ask for all make a level -unwinnable without failing anything. - -Each case prepares a throwaway workspace, replays the author's walkthrough, and -asserts `validate()` accepts it. -""" - -from __future__ import annotations - -import os -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any - -import pytest - -from shellgame.levels.base import Level -from shellgame.levels.completion import ( - AtDirectory, - AtHome, - DirectoryExists, - FileExists, - FileLineCount, - PathMoved, - PathsMatch, - PermissionBits, - PermissionMode, - TextFileContent, -) -from shellgame.levels.loader import initialize_levels -from shellgame.levels.registry import get_registry - - -@dataclass -class _State: - workspace: Path - username: str = "solver" - current_level: str = "" - level_hints_used: dict[str, int] = field(default_factory=dict) - levels_complete: dict[str, Any] = field(default_factory=dict) - - -def _levels() -> list[Level]: - initialize_levels() - return get_registry().list_levels() - - -def _solvable_levels() -> list[Level]: - return [level for level in _levels() if level.solution is not None] - - -def _ids(levels: list[Level]) -> list[str]: - return [level.id for level in levels] - - -@pytest.fixture -def restore_cwd() -> Any: - origin = Path.cwd() - yield - os.chdir(origin) - - -SOLVABLE = _solvable_levels() - - -@pytest.mark.parametrize("level", SOLVABLE, ids=_ids(SOLVABLE)) -def test_declared_solution_completes_the_level(level: Level, tmp_path: Path, restore_cwd: None) -> None: - """The author's own walkthrough must satisfy the level's own rules.""" - del restore_cwd - workspace = tmp_path / "workspace" - workspace.mkdir() - state = _State(workspace=workspace, current_level=level.id) - - level.prepare(workspace) - solution = level.solution - assert solution is not None - solution.perform(level, state) - - passed, message = level.validate(solution.answer, state) - assert passed, f"{level.id} is not completable by its own solution: {message}" - - -@pytest.mark.parametrize("level", SOLVABLE, ids=_ids(SOLVABLE)) -def test_solution_is_repeatable_after_reset(level: Level, tmp_path: Path, restore_cwd: None) -> None: - """`shellgame reset` must hand the player a level that is still winnable. - - Reset re-applies the fixture over a workspace the player has already - modified. A fixture that only creates missing files, or a marker that reset - forgets to clear, would leave the second attempt unwinnable. - """ - del restore_cwd - workspace = tmp_path / "workspace" - workspace.mkdir() - state = _State(workspace=workspace, current_level=level.id) - solution = level.solution - assert solution is not None - - level.prepare(workspace) - solution.perform(level, state) - - os.chdir(tmp_path) - level.reset(workspace) - solution.perform(level, state) - - passed, message = level.validate(solution.answer, state) - assert passed, f"{level.id} is not completable after reset: {message}" - - -def test_risky_levels_declare_a_solution() -> None: - """The shapes that can silently become unwinnable must stay covered. - - A plain level whose fixture and requirements share one vocabulary is hard to - get wrong. Custom code and evidence markers are not, so those levels are - required to ship a walkthrough. - """ - missing: list[str] = [] - for level in _levels(): - if level.is_intro or level.solution is not None: - continue - reasons = _risk_reasons(level) - if reasons: - missing.append(f"{level.id} ({', '.join(reasons)})") - - assert not missing, "These levels can silently become unwinnable and must declare a Solution:\n " + "\n ".join( - missing - ) - - -_FILESYSTEM_RULES = ( - AtDirectory, - AtHome, - DirectoryExists, - FileExists, - FileLineCount, - PathMoved, - PathsMatch, - PermissionBits, - PermissionMode, - TextFileContent, -) - - -def _risk_reasons(level: Level) -> list[str]: - reasons = [] - if type(level).validate is not Level.validate: - reasons.append("custom validate()") - if type(level).setup is not Level.setup: - reasons.append("custom setup()") - if level.cd_policy is not None or "cd" in level.hooks: - reasons.append("cd hook") - if level.completion is not None and level.completion.evidence_markers: - reasons.append("evidence marker") - if level.completion is not None and any( - isinstance(rule, _FILESYSTEM_RULES) for rule in level.completion.requirements - ): - reasons.append("filesystem requirement") - return reasons diff --git a/tests/test_stale_level_recovery.py b/tests/test_stale_level_recovery.py deleted file mode 100644 index 65a2528..0000000 --- a/tests/test_stale_level_recovery.py +++ /dev/null @@ -1,152 +0,0 @@ -"""A saved game must survive levels being renumbered between versions. - -Level IDs are persisted in `state.json`. Before this, a saved `current_level` -that no longer existed made every gameplay command print "level not found", -leaving `shellgame remove` — total progress loss — as the only way out. -""" - -from __future__ import annotations - -from datetime import datetime -from pathlib import Path - -import pytest - -from shellgame.core.session import GameSession -from shellgame.levels.base import Level -from shellgame.levels.completion import Completion -from shellgame.levels.registry import LevelRegistry, UnknownLevelError -from shellgame.state.manager import GameState, LevelCompletion -from tests.fakes import ( - _FakeConsole, - _FakeDisplay, - _FakeLevel, - _FakeRegistry, - _FakeShell, - _FakeStateManager, - _FakeWorkspace, -) - - -def _level(level_id: str) -> Level: - class _Registered(Level): - id = level_id - title = f"Level {level_id}" - completion = Completion() - - return _Registered() - - -def _registry(*ids: str) -> LevelRegistry: - registry = LevelRegistry() - for level_id in ids: - registry.register(_level(level_id)) - return registry - - -class TestNextLevel: - def test_last_level_returns_none(self) -> None: - assert _registry("1.1", "1.2").next_level("1.2") is None - - def test_unknown_level_raises_instead_of_looking_finished(self) -> None: - """Returning None here would silently declare the game complete.""" - with pytest.raises(UnknownLevelError): - _registry("1.1", "1.2").next_level("9.9") - - def test_known_level_advances(self) -> None: - assert _registry("1.1", "1.2").next_level("1.1") == "1.2" - - -class TestResolveOrNearest: - def test_existing_id_is_returned_unchanged(self) -> None: - assert _registry("1.1", "2.1").resolve_or_nearest("2.1") == "2.1" - - def test_removed_level_resolves_forward_within_the_section(self) -> None: - assert _registry("1.1", "1.5", "2.1").resolve_or_nearest("1.3") == "1.5" - - def test_removed_section_resolves_to_the_next_section(self) -> None: - assert _registry("1.1", "3.1").resolve_or_nearest("2.4") == "3.1" - - def test_id_past_the_end_resolves_to_the_last_level(self) -> None: - assert _registry("1.1", "1.2").resolve_or_nearest("9.9") == "1.2" - - def test_malformed_id_resolves_to_the_first_level(self) -> None: - assert _registry("1.1", "1.2").resolve_or_nearest("garbage") == "1.1" - - def test_empty_registry_resolves_to_nothing(self) -> None: - assert LevelRegistry().resolve_or_nearest("1.1") is None - - -class TestSessionRecovery: - def _session(self, tmp_path: Path, current_level: str) -> tuple[GameSession, _FakeDisplay, GameState]: - workspace = tmp_path / "ws" - workspace.mkdir(parents=True, exist_ok=True) - state = GameState( - username="student", - current_level=current_level, - workspace=workspace, - start_time=datetime.now(), - ) - display = _FakeDisplay() - registry = _FakeRegistry([_FakeLevel("1.1"), _FakeLevel("1.2")], nearest="1.1") - - session = GameSession( - console=_FakeConsole(), - display=display, # type: ignore[arg-type] - state_manager=_FakeStateManager(state), # type: ignore[arg-type] - level_registry=registry, # type: ignore[arg-type] - teleport_notice=lambda _destination: None, - shell_client=_FakeShell(), # type: ignore[arg-type] - workspace_factory=lambda _workspace: _FakeWorkspace(workspace), # type: ignore[arg-type] - ) - return session, display, state - - @pytest.mark.parametrize("action", ["submit", "skip", "reset", "hint"]) - def test_stale_level_resyncs_instead_of_bricking(self, tmp_path: Path, action: str) -> None: - session, display, state = self._session(tmp_path, "7.3") - - if action == "submit": - session.submit("x") - elif action == "skip": - session.skip() - elif action == "reset": - session.reset() - else: - session.hint(repeat=False) - - assert "show_level_missing" in display.names() - assert state.current_level == "1.1" - - def test_resync_does_not_apply_the_stale_command_to_the_new_level(self, tmp_path: Path) -> None: - """An answer typed for level 7.3 must not be graded against level 1.1.""" - session, display, state = self._session(tmp_path, "7.3") - - session.submit("an answer for a level that no longer exists") - - assert "show_success" not in display.names() - assert state.current_level == "1.1" - assert "1.1" not in state.levels_complete - - def test_resync_preserves_completed_levels(self, tmp_path: Path) -> None: - session, _, state = self._session(tmp_path, "7.3") - state.levels_complete["1.1"] = LevelCompletion(time_sec=10, hints=0, attempts=1, completed_at=datetime.now()) - - session.submit("x") - - assert "1.1" in state.levels_complete - - def test_resync_never_declares_the_game_complete(self, tmp_path: Path) -> None: - session, display, state = self._session(tmp_path, "7.3") - - session.submit("x") - - assert state.completed_at is None - assert "show_game_complete" not in display.names() - - def test_valid_level_is_untouched(self, tmp_path: Path) -> None: - session, display, state = self._session(tmp_path, "1.1") - - session.submit("x") - - assert "show_level_missing" not in display.names() - assert state.current_level == "1.2" diff --git a/tests/test_state.py b/tests/test_state.py deleted file mode 100644 index d718402..0000000 --- a/tests/test_state.py +++ /dev/null @@ -1,298 +0,0 @@ -import json -from datetime import datetime -from pathlib import Path - -import pytest - -from shellgame.levels.loader import initialize_levels -from shellgame.levels.registry import get_registry -from shellgame.state.manager import ( - _LEVEL_SWAPS_2_1, - _SECTION_ROTATION_2_1, - CURRENT_STATE_VERSION, - GameState, - LevelCompletion, - StateLoadError, - StateManager, - StateSaveError, - _migrate_level_id_to_2_1, -) - - -class TestGameState: - def test_create_state(self) -> None: - workspace = Path("/tmp/test-workspace") - state = GameState( - username="testuser", - workspace=workspace, - current_level="1.1", - start_time=datetime.now(), - ) - - assert state.username == "testuser" - assert state.workspace == workspace - assert state.current_level == "1.1" - assert isinstance(state.start_time, datetime) - - # Tracking defaults - assert state.level_attempts == {} - assert state.level_hints_used == {} - assert state.level_started_at == {} - assert state.levels_complete == {} - - def test_level_completion(self) -> None: - state = GameState( - username="testuser", - workspace=Path("/tmp/test"), - current_level="1.1", - start_time=datetime.now(), - ) - - completion = LevelCompletion(time_sec=45, hints=1, attempts=2, completed_at=datetime.now()) - - state.levels_complete["1.1"] = completion - assert "1.1" in state.levels_complete - assert state.levels_complete["1.1"].time_sec == 45 - - -class TestStateManager: - def test_init_state(self, tmp_path: Path) -> None: - manager = StateManager() - manager.state_dir = tmp_path - manager.state_file = tmp_path / "state.json" - - state = manager.init("testuser") - - assert state.username == "testuser" - assert state.current_level == "0.0" - assert manager.state_file.exists() - - # Current level timer starts immediately - assert state.current_level in state.level_started_at - - def test_save_and_load(self, tmp_path: Path) -> None: - manager = StateManager() - manager.state_dir = tmp_path - manager.state_file = tmp_path / "state.json" - - # Create and save state - state = manager.init("testuser") - state.current_level = "2.3" - manager.save(state) - - # Load state - loaded = manager.load() - - assert loaded is not None - assert loaded.username == "testuser" - assert loaded.current_level == "2.3" - - def test_save_removes_temp_file_when_serialization_fails( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - manager = StateManager() - manager.state_dir = tmp_path - manager.state_file = tmp_path / "state.json" - state = manager.create("testuser") - - def fail_serialization(self: GameState, *, indent: int) -> str: - raise OSError("disk full") - - monkeypatch.setattr(GameState, "model_dump_json", fail_serialization) - - with pytest.raises(StateSaveError, match="disk full"): - manager.save(state) - - assert list(tmp_path.iterdir()) == [] - - def test_load_nonexistent(self, tmp_path: Path) -> None: - manager = StateManager() - manager.state_dir = tmp_path - manager.state_file = tmp_path / "nonexistent.json" - - state = manager.load() - assert state is None - - def test_delete_state(self, tmp_path: Path) -> None: - manager = StateManager() - manager.state_dir = tmp_path - manager.state_file = tmp_path / "state.json" - - manager.init("testuser") - assert manager.state_file.exists() - - manager.remove() - assert not manager.state_file.exists() - - def test_exists(self, tmp_path: Path) -> None: - manager = StateManager() - manager.state_dir = tmp_path - manager.state_file = tmp_path / "state.json" - - assert not manager.exists() - - manager.init("testuser") - assert manager.exists() - - def test_save_and_load_persists_tracking_fields(self, tmp_path: Path) -> None: - manager = StateManager() - manager.state_dir = tmp_path - manager.state_file = tmp_path / "state.json" - - state = manager.init("testuser") - state.current_level = "1.1" - state.level_attempts["1.1"] = 3 - state.level_hints_used["1.1"] = 2 - state.level_started_at["1.1"] = datetime.now() - - manager.save(state) - loaded = manager.load() - - assert loaded is not None - assert loaded.level_attempts["1.1"] == 3 - assert loaded.level_hints_used["1.1"] == 2 - assert isinstance(loaded.level_started_at["1.1"], datetime) - - def test_load_migrates_version_1_state(self, tmp_path: Path) -> None: - manager = StateManager() - manager.state_dir = tmp_path - manager.state_file = tmp_path / "state.json" - manager.state_file.write_text( - json.dumps( - { - "version": "1.0", - "username": "testuser", - "workspace": "/tmp/test", - "current_level": "1.1", - "start_time": datetime.now().isoformat(), - } - ) - ) - - loaded = manager.load() - - assert loaded is not None - assert loaded.version == CURRENT_STATE_VERSION - - def test_corrupt_state_raises_without_overwriting_file(self, tmp_path: Path) -> None: - manager = StateManager() - manager.state_dir = tmp_path - manager.state_file = tmp_path / "state.json" - manager.state_file.write_text("{not-json") - - with pytest.raises(StateLoadError): - manager.load() - - assert manager.state_file.read_text() == "{not-json" - - def test_unsupported_state_version_raises(self, tmp_path: Path) -> None: - manager = StateManager() - manager.state_dir = tmp_path - manager.state_file = tmp_path / "state.json" - manager.state_file.write_text( - json.dumps( - { - "version": "99.0", - "username": "testuser", - "workspace": "/tmp/test", - "current_level": "1.1", - "start_time": datetime.now().isoformat(), - } - ) - ) - - with pytest.raises(StateLoadError, match="Nepodporovaná verze"): - manager.load() - - -class TestSectionReorderMigration: - """State 2.1 renumbered sections; a pre-2.1 save must keep its real progress. - - Without an explicit remap the registry's nearest-match fallback would resume - the player on whatever level now carries their saved number - different - content - and credit their finished levels to the wrong section. - """ - - def _load(self, tmp_path: Path, payload: dict) -> GameState: - manager = StateManager() - manager.state_dir = tmp_path - manager.state_file = tmp_path / "state.json" - manager.state_file.write_text(json.dumps(payload)) - loaded = manager.load() - assert loaded is not None - return loaded - - def _payload(self, **overrides: object) -> dict: - payload: dict = { - "version": "2.0", - "username": "testuser", - "workspace": "/tmp/test", - "current_level": "1.1", - "start_time": datetime.now().isoformat(), - } - payload.update(overrides) - return payload - - @pytest.mark.parametrize( - ("saved", "expected"), - [ - ("1.1", "1.1"), # untouched sections keep their IDs - ("5.4", "5.4"), - ("11.6", "11.6"), - ("7.2", "8.2"), # permissions moved back one slot - ("8.5", "9.5"), # redirection moved back one slot - ("9.4", "10.4"), # error streams moved back one slot - ("10.3", "7.3"), # wildcards moved forward - ("2.7", "2.8"), # help lesson and challenge swapped - ("2.8", "2.7"), - ("6.7", "6.8"), # alias aside and challenge swapped - ("6.8", "6.7"), - ], - ) - def test_current_level_follows_its_content(self, tmp_path: Path, saved: str, expected: str) -> None: - loaded = self._load(tmp_path, self._payload(current_level=saved)) - - assert loaded.current_level == expected - assert loaded.version == CURRENT_STATE_VERSION - - def test_progress_keyed_by_level_id_is_remapped_too(self, tmp_path: Path) -> None: - completion = {"time_sec": 12, "hints": 1, "attempts": 0, "completed_at": datetime.now().isoformat()} - loaded = self._load( - tmp_path, - self._payload( - current_level="10.1", - levels_complete={"7.1": completion}, - level_attempts={"8.5": 3}, - level_hints_used={"9.4": 2}, - level_started_at={"10.3": datetime.now().isoformat()}, - ), - ) - - assert loaded.current_level == "7.1" - assert set(loaded.levels_complete) == {"8.1"} - assert loaded.level_attempts == {"9.5": 3} - assert loaded.level_hints_used == {"10.4": 2} - assert set(loaded.level_started_at) == {"7.3"} - - def test_every_migrated_id_resolves_to_a_registered_level(self) -> None: - """Round-trip: rebuild each level's pre-2.1 ID, then migrate it forward.""" - initialize_levels() - registry = get_registry() - inverse_rotation = {new: old for old, new in _SECTION_ROTATION_2_1.items()} - - def previous_id(level_id: str) -> str: - section, _, number = level_id.partition(".") - old = f"{inverse_rotation.get(int(section), int(section))}.{number}" - # The swaps are their own inverse, so reapplying them rebuilds the old ID. - return _LEVEL_SWAPS_2_1.get(old, old) - - old_ids = [previous_id(str(level.id)) for level in registry.list_levels()] - migrated = [_migrate_level_id_to_2_1(old_id) for old_id in old_ids] - - assert migrated == [str(level.id) for level in registry.list_levels()] - assert len(set(migrated)) == len(old_ids), "migration must not collapse two levels onto one" - - def test_already_migrated_state_is_left_alone(self, tmp_path: Path) -> None: - loaded = self._load(tmp_path, self._payload(version=CURRENT_STATE_VERSION, current_level="7.2")) - - assert loaded.current_level == "7.2" diff --git a/tests/test_workspace.py b/tests/test_workspace.py deleted file mode 100644 index 3dd79b8..0000000 --- a/tests/test_workspace.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Unit tests for workspace management.""" - -from pathlib import Path - -import pytest - -from shellgame.state.manager import StateManager -from shellgame.workspace.builder import WorkspaceManager - - -class TestWorkspaceManager: - def test_init_workspace(self, tmp_path: Path) -> None: - manager = WorkspaceManager(tmp_path / "workspace") - - result = manager.init() - - assert result.is_dir() - - def test_validate_integrity(self, tmp_path: Path) -> None: - manager = WorkspaceManager(tmp_path / "workspace") - manager.init() - - assert manager.validate_integrity() is True - - def test_remove(self, tmp_path: Path) -> None: - manager = WorkspaceManager(tmp_path / "workspace") - manager.init() - - manager.remove() - - assert not manager.workspace_root.exists() - - def test_remove_rejects_path_outside_temp_directory(self) -> None: - manager = WorkspaceManager(Path("/")) - - with pytest.raises(RuntimeError, match="unsafe workspace"): - manager.remove() - - -def test_workspace_manager_uses_the_path_it_is_given(tmp_path: Path) -> None: - """Restore and remove must act on `state.workspace`, never on a re-derived path.""" - explicit = tmp_path / "custom-location" - - manager = WorkspaceManager(explicit) - manager.init() - - assert manager.workspace_root == explicit - assert explicit.is_dir() - - -def test_default_workspace_is_derived_in_exactly_one_place() -> None: - assert StateManager.default_workspace("ada") == Path("/tmp/shellgame-ada") - assert StateManager().create("ada").workspace == StateManager.default_workspace("ada") diff --git a/tests/ui/test_display_hints.py b/tests/ui/test_display_hints.py deleted file mode 100644 index 06aad06..0000000 --- a/tests/ui/test_display_hints.py +++ /dev/null @@ -1,126 +0,0 @@ -"""Focused tests for Display hint repeat rules. - -These tests validate the UX contract around: -- progressive hint consumption (`shellgame hint`) -- repeat semantics (`shellgame hint --repeat`) without consuming new hints -- footer printing rules (repeat tip once at end, next-help only if more hints exist) -- no-more-hints panel -""" - -from __future__ import annotations - -from rich.console import Console - -from shellgame.ui.display import Display - - -def _make_display() -> tuple[Display, Console]: - console = Console(record=True, width=120) - display = Display(console) - return display, console - - -def _output(console: Console) -> str: - return console.export_text() - - -def test_show_hint_output() -> None: - display, console = _make_display() - - display.show_hint("H1", 0, 3) - - out = _output(console) - assert "H1" in out - assert "shellgame hint --repeat" in out # repeat tip under normal hint - assert "Potřebujete další pomoc? Napište: shellgame hint" in out # next-help shown - - -def test_show_hint_preserves_bracket_globs_as_literal_text() -> None: - display, console = _make_display() - hint = "V Bashi: `cp file_[ab].txt ab_files/`; `cp [a-z]*.txt lowercase/`." - - display.show_hint(hint, 0, 1) - - out = _output(console) - assert "file_[ab].txt" in out - assert "[a-z]*.txt" in out - - -def test_repeated_hints_preserve_bracket_globs_as_literal_text() -> None: - display, console = _make_display() - hints = ["`cp file_[ab].txt ab_files/`", "`cp [a-z]*.txt lowercase/`"] - - display.show_repeated_hints(hints, 2) - - out = _output(console) - assert "file_[ab].txt" in out - assert "[a-z]*.txt" in out - - -def test_repeat_with_no_revealed_hints_does_not_leak_a_hint() -> None: - """`--repeat` must never reveal an unconsumed hint (it would be a free hint - and would desynchronise the hint counter shown by `shellgame status`).""" - display, console = _make_display() - hints = ["H1", "H2"] - - display.show_repeated_hints(hints, 0) - - out = _output(console) - assert "H1" not in out - assert "H2" not in out - assert "Zatím jste si nezobrazili žádnou nápovědu." in out - # Points at the command that actually reveals a hint. - assert "shellgame hint" in out - - -def test_repeat_reprints_only_revealed_hints() -> None: - display, console = _make_display() - hints = ["H1", "H2", "H3"] - - display.show_repeated_hints(hints, 2) - - out = _output(console) - assert "H1" in out - assert "H2" in out - assert "H3" not in out - - # In --repeat flow: no per-hint footer spam; repeat tip once at end - assert out.count("shellgame hint --repeat") == 1 - # Another hint still exists (3rd), so next-help may be shown once at end - assert out.count("Potřebujete další pomoc? Napište: shellgame hint") == 1 - - -def test_repeat_when_all_hints_revealed_does_not_show_next_help() -> None: - display, console = _make_display() - hints = ["H1", "H2", "H3"] - - display.show_repeated_hints(hints, 3) - - out = _output(console) - assert "H1" in out and "H2" in out and "H3" in out - # Repeat tip once at end - assert out.count("shellgame hint --repeat") == 1 - # No more hints exist -> do not show next-help - assert "Potřebujete další pomoc? Napište: shellgame hint" not in out - - -def test_no_more_hints_panel() -> None: - display, console = _make_display() - - display.show_no_more_hints() - - out = _output(console) - assert "Pro tento level již nejsou k dispozici žádné další nápovědy." in out - # The UX rule says to show repeat tip below this panel (note with violet command) - assert "shellgame hint --repeat" in out - - -def test_repeat_does_not_print_next_help_multiple_times() -> None: - display, console = _make_display() - hints = ["H1", "H2", "H3", "H4"] - - display.show_repeated_hints(hints, 2) - - out = _output(console) - # Ensure helper line is not repeated per-hint - assert out.count("Potřebujete další pomoc? Napište: shellgame hint") == 1 diff --git a/uv.lock b/uv.lock deleted file mode 100644 index e196e67..0000000 --- a/uv.lock +++ /dev/null @@ -1,742 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.10" - -[[package]] -name = "altgraph" -version = "0.17.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7e/f8/97fdf103f38fed6792a1601dbc16cc8aac56e7459a9fff08c812d8ae177a/altgraph-0.17.5.tar.gz", hash = "sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7", size = 48428, upload-time = "2025-11-21T20:35:50.583Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228, upload-time = "2025-11-21T20:35:49.444Z" }, -] - -[[package]] -name = "annotated-types" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, -] - -[[package]] -name = "click" -version = "8.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, -] - -[[package]] -name = "coverage" -version = "7.13.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b6/45/2c665ca77ec32ad67e25c77daf1cee28ee4558f3bc571cdbaf88a00b9f23/coverage-7.13.0.tar.gz", hash = "sha256:a394aa27f2d7ff9bc04cf703817773a59ad6dfbd577032e690f961d2460ee936", size = 820905, upload-time = "2025-12-08T13:14:38.055Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/db/08/bdd7ccca14096f7eb01412b87ac11e5d16e4cb54b6e328afc9dee8bdaec1/coverage-7.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:02d9fb9eccd48f6843c98a37bd6817462f130b86da8660461e8f5e54d4c06070", size = 217979, upload-time = "2025-12-08T13:12:14.505Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f0/d1302e3416298a28b5663ae1117546a745d9d19fde7e28402b2c5c3e2109/coverage-7.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:367449cf07d33dc216c083f2036bb7d976c6e4903ab31be400ad74ad9f85ce98", size = 218496, upload-time = "2025-12-08T13:12:16.237Z" }, - { url = "https://files.pythonhosted.org/packages/07/26/d36c354c8b2a320819afcea6bffe72839efd004b98d1d166b90801d49d57/coverage-7.13.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cdb3c9f8fef0a954c632f64328a3935988d33a6604ce4bf67ec3e39670f12ae5", size = 245237, upload-time = "2025-12-08T13:12:17.858Z" }, - { url = "https://files.pythonhosted.org/packages/91/52/be5e85631e0eec547873d8b08dd67a5f6b111ecfe89a86e40b89b0c1c61c/coverage-7.13.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d10fd186aac2316f9bbb46ef91977f9d394ded67050ad6d84d94ed6ea2e8e54e", size = 247061, upload-time = "2025-12-08T13:12:19.132Z" }, - { url = "https://files.pythonhosted.org/packages/0f/45/a5e8fa0caf05fbd8fa0402470377bff09cc1f026d21c05c71e01295e55ab/coverage-7.13.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f88ae3e69df2ab62fb0bc5219a597cb890ba5c438190ffa87490b315190bb33", size = 248928, upload-time = "2025-12-08T13:12:20.702Z" }, - { url = "https://files.pythonhosted.org/packages/f5/42/ffb5069b6fd1b95fae482e02f3fecf380d437dd5a39bae09f16d2e2e7e01/coverage-7.13.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4be718e51e86f553bcf515305a158a1cd180d23b72f07ae76d6017c3cc5d791", size = 245931, upload-time = "2025-12-08T13:12:22.243Z" }, - { url = "https://files.pythonhosted.org/packages/95/6e/73e809b882c2858f13e55c0c36e94e09ce07e6165d5644588f9517efe333/coverage-7.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a00d3a393207ae12f7c49bb1c113190883b500f48979abb118d8b72b8c95c032", size = 246968, upload-time = "2025-12-08T13:12:23.52Z" }, - { url = "https://files.pythonhosted.org/packages/87/08/64ebd9e64b6adb8b4a4662133d706fbaccecab972e0b3ccc23f64e2678ad/coverage-7.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a7b1cd820e1b6116f92c6128f1188e7afe421c7e1b35fa9836b11444e53ebd9", size = 244972, upload-time = "2025-12-08T13:12:24.781Z" }, - { url = "https://files.pythonhosted.org/packages/12/97/f4d27c6fe0cb375a5eced4aabcaef22de74766fb80a3d5d2015139e54b22/coverage-7.13.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:37eee4e552a65866f15dedd917d5e5f3d59805994260720821e2c1b51ac3248f", size = 245241, upload-time = "2025-12-08T13:12:28.041Z" }, - { url = "https://files.pythonhosted.org/packages/0c/94/42f8ae7f633bf4c118bf1038d80472f9dade88961a466f290b81250f7ab7/coverage-7.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:62d7c4f13102148c78d7353c6052af6d899a7f6df66a32bddcc0c0eb7c5326f8", size = 245847, upload-time = "2025-12-08T13:12:29.337Z" }, - { url = "https://files.pythonhosted.org/packages/a8/2f/6369ca22b6b6d933f4f4d27765d313d8914cc4cce84f82a16436b1a233db/coverage-7.13.0-cp310-cp310-win32.whl", hash = "sha256:24e4e56304fdb56f96f80eabf840eab043b3afea9348b88be680ec5986780a0f", size = 220573, upload-time = "2025-12-08T13:12:30.905Z" }, - { url = "https://files.pythonhosted.org/packages/f1/dc/a6a741e519acceaeccc70a7f4cfe5d030efc4b222595f0677e101af6f1f3/coverage-7.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:74c136e4093627cf04b26a35dab8cbfc9b37c647f0502fc313376e11726ba303", size = 221509, upload-time = "2025-12-08T13:12:32.09Z" }, - { url = "https://files.pythonhosted.org/packages/f1/dc/888bf90d8b1c3d0b4020a40e52b9f80957d75785931ec66c7dfaccc11c7d/coverage-7.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0dfa3855031070058add1a59fdfda0192fd3e8f97e7c81de0596c145dea51820", size = 218104, upload-time = "2025-12-08T13:12:33.333Z" }, - { url = "https://files.pythonhosted.org/packages/8d/ea/069d51372ad9c380214e86717e40d1a743713a2af191cfba30a0911b0a4a/coverage-7.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4fdb6f54f38e334db97f72fa0c701e66d8479af0bc3f9bfb5b90f1c30f54500f", size = 218606, upload-time = "2025-12-08T13:12:34.498Z" }, - { url = "https://files.pythonhosted.org/packages/68/09/77b1c3a66c2aa91141b6c4471af98e5b1ed9b9e6d17255da5eb7992299e3/coverage-7.13.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7e442c013447d1d8d195be62852270b78b6e255b79b8675bad8479641e21fd96", size = 248999, upload-time = "2025-12-08T13:12:36.02Z" }, - { url = "https://files.pythonhosted.org/packages/0a/32/2e2f96e9d5691eaf1181d9040f850b8b7ce165ea10810fd8e2afa534cef7/coverage-7.13.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ed5630d946859de835a85e9a43b721123a8a44ec26e2830b296d478c7fd4259", size = 250925, upload-time = "2025-12-08T13:12:37.221Z" }, - { url = "https://files.pythonhosted.org/packages/7b/45/b88ddac1d7978859b9a39a8a50ab323186148f1d64bc068f86fc77706321/coverage-7.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f15a931a668e58087bc39d05d2b4bf4b14ff2875b49c994bbdb1c2217a8daeb", size = 253032, upload-time = "2025-12-08T13:12:38.763Z" }, - { url = "https://files.pythonhosted.org/packages/71/cb/e15513f94c69d4820a34b6bf3d2b1f9f8755fa6021be97c7065442d7d653/coverage-7.13.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30a3a201a127ea57f7e14ba43c93c9c4be8b7d17a26e03bb49e6966d019eede9", size = 249134, upload-time = "2025-12-08T13:12:40.382Z" }, - { url = "https://files.pythonhosted.org/packages/09/61/d960ff7dc9e902af3310ce632a875aaa7860f36d2bc8fc8b37ee7c1b82a5/coverage-7.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a485ff48fbd231efa32d58f479befce52dcb6bfb2a88bb7bf9a0b89b1bc8030", size = 250731, upload-time = "2025-12-08T13:12:41.992Z" }, - { url = "https://files.pythonhosted.org/packages/98/34/c7c72821794afc7c7c2da1db8f00c2c98353078aa7fb6b5ff36aac834b52/coverage-7.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:22486cdafba4f9e471c816a2a5745337742a617fef68e890d8baf9f3036d7833", size = 248795, upload-time = "2025-12-08T13:12:43.331Z" }, - { url = "https://files.pythonhosted.org/packages/0a/5b/e0f07107987a43b2def9aa041c614ddb38064cbf294a71ef8c67d43a0cdd/coverage-7.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:263c3dbccc78e2e331e59e90115941b5f53e85cfcc6b3b2fbff1fd4e3d2c6ea8", size = 248514, upload-time = "2025-12-08T13:12:44.546Z" }, - { url = "https://files.pythonhosted.org/packages/71/c2/c949c5d3b5e9fc6dd79e1b73cdb86a59ef14f3709b1d72bf7668ae12e000/coverage-7.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5330fa0cc1f5c3c4c3bb8e101b742025933e7848989370a1d4c8c5e401ea753", size = 249424, upload-time = "2025-12-08T13:12:45.759Z" }, - { url = "https://files.pythonhosted.org/packages/11/f1/bbc009abd6537cec0dffb2cc08c17a7f03de74c970e6302db4342a6e05af/coverage-7.13.0-cp311-cp311-win32.whl", hash = "sha256:0f4872f5d6c54419c94c25dd6ae1d015deeb337d06e448cd890a1e89a8ee7f3b", size = 220597, upload-time = "2025-12-08T13:12:47.378Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f6/d9977f2fb51c10fbaed0718ce3d0a8541185290b981f73b1d27276c12d91/coverage-7.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51a202e0f80f241ccb68e3e26e19ab5b3bf0f813314f2c967642f13ebcf1ddfe", size = 221536, upload-time = "2025-12-08T13:12:48.7Z" }, - { url = "https://files.pythonhosted.org/packages/be/ad/3fcf43fd96fb43e337a3073dea63ff148dcc5c41ba7a14d4c7d34efb2216/coverage-7.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:d2a9d7f1c11487b1c69367ab3ac2d81b9b3721f097aa409a3191c3e90f8f3dd7", size = 220206, upload-time = "2025-12-08T13:12:50.365Z" }, - { url = "https://files.pythonhosted.org/packages/9b/f1/2619559f17f31ba00fc40908efd1fbf1d0a5536eb75dc8341e7d660a08de/coverage-7.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0b3d67d31383c4c68e19a88e28fc4c2e29517580f1b0ebec4a069d502ce1e0bf", size = 218274, upload-time = "2025-12-08T13:12:52.095Z" }, - { url = "https://files.pythonhosted.org/packages/2b/11/30d71ae5d6e949ff93b2a79a2c1b4822e00423116c5c6edfaeef37301396/coverage-7.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:581f086833d24a22c89ae0fe2142cfaa1c92c930adf637ddf122d55083fb5a0f", size = 218638, upload-time = "2025-12-08T13:12:53.418Z" }, - { url = "https://files.pythonhosted.org/packages/79/c2/fce80fc6ded8d77e53207489d6065d0fed75db8951457f9213776615e0f5/coverage-7.13.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0a3a30f0e257df382f5f9534d4ce3d4cf06eafaf5192beb1a7bd066cb10e78fb", size = 250129, upload-time = "2025-12-08T13:12:54.744Z" }, - { url = "https://files.pythonhosted.org/packages/5b/b6/51b5d1eb6fcbb9a1d5d6984e26cbe09018475c2922d554fd724dd0f056ee/coverage-7.13.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:583221913fbc8f53b88c42e8dbb8fca1d0f2e597cb190ce45916662b8b9d9621", size = 252885, upload-time = "2025-12-08T13:12:56.401Z" }, - { url = "https://files.pythonhosted.org/packages/0d/f8/972a5affea41de798691ab15d023d3530f9f56a72e12e243f35031846ff7/coverage-7.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f5d9bd30756fff3e7216491a0d6d520c448d5124d3d8e8f56446d6412499e74", size = 253974, upload-time = "2025-12-08T13:12:57.718Z" }, - { url = "https://files.pythonhosted.org/packages/8a/56/116513aee860b2c7968aa3506b0f59b22a959261d1dbf3aea7b4450a7520/coverage-7.13.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a23e5a1f8b982d56fa64f8e442e037f6ce29322f1f9e6c2344cd9e9f4407ee57", size = 250538, upload-time = "2025-12-08T13:12:59.254Z" }, - { url = "https://files.pythonhosted.org/packages/d6/75/074476d64248fbadf16dfafbf93fdcede389ec821f74ca858d7c87d2a98c/coverage-7.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b01c22bc74a7fb44066aaf765224c0d933ddf1f5047d6cdfe4795504a4493f8", size = 251912, upload-time = "2025-12-08T13:13:00.604Z" }, - { url = "https://files.pythonhosted.org/packages/f2/d2/aa4f8acd1f7c06024705c12609d8698c51b27e4d635d717cd1934c9668e2/coverage-7.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:898cce66d0836973f48dda4e3514d863d70142bdf6dfab932b9b6a90ea5b222d", size = 250054, upload-time = "2025-12-08T13:13:01.892Z" }, - { url = "https://files.pythonhosted.org/packages/19/98/8df9e1af6a493b03694a1e8070e024e7d2cdc77adedc225a35e616d505de/coverage-7.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3ab483ea0e251b5790c2aac03acde31bff0c736bf8a86829b89382b407cd1c3b", size = 249619, upload-time = "2025-12-08T13:13:03.236Z" }, - { url = "https://files.pythonhosted.org/packages/d8/71/f8679231f3353018ca66ef647fa6fe7b77e6bff7845be54ab84f86233363/coverage-7.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d84e91521c5e4cb6602fe11ece3e1de03b2760e14ae4fcf1a4b56fa3c801fcd", size = 251496, upload-time = "2025-12-08T13:13:04.511Z" }, - { url = "https://files.pythonhosted.org/packages/04/86/9cb406388034eaf3c606c22094edbbb82eea1fa9d20c0e9efadff20d0733/coverage-7.13.0-cp312-cp312-win32.whl", hash = "sha256:193c3887285eec1dbdb3f2bd7fbc351d570ca9c02ca756c3afbc71b3c98af6ef", size = 220808, upload-time = "2025-12-08T13:13:06.422Z" }, - { url = "https://files.pythonhosted.org/packages/1c/59/af483673df6455795daf5f447c2f81a3d2fcfc893a22b8ace983791f6f34/coverage-7.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:4f3e223b2b2db5e0db0c2b97286aba0036ca000f06aca9b12112eaa9af3d92ae", size = 221616, upload-time = "2025-12-08T13:13:07.95Z" }, - { url = "https://files.pythonhosted.org/packages/64/b0/959d582572b30a6830398c60dd419c1965ca4b5fb38ac6b7093a0d50ca8d/coverage-7.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:086cede306d96202e15a4b77ace8472e39d9f4e5f9fd92dd4fecdfb2313b2080", size = 220261, upload-time = "2025-12-08T13:13:09.581Z" }, - { url = "https://files.pythonhosted.org/packages/7c/cc/bce226595eb3bf7d13ccffe154c3c487a22222d87ff018525ab4dd2e9542/coverage-7.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:28ee1c96109974af104028a8ef57cec21447d42d0e937c0275329272e370ebcf", size = 218297, upload-time = "2025-12-08T13:13:10.977Z" }, - { url = "https://files.pythonhosted.org/packages/3b/9f/73c4d34600aae03447dff3d7ad1d0ac649856bfb87d1ca7d681cfc913f9e/coverage-7.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e97353dcc5587b85986cda4ff3ec98081d7e84dd95e8b2a6d59820f0545f8a", size = 218673, upload-time = "2025-12-08T13:13:12.562Z" }, - { url = "https://files.pythonhosted.org/packages/63/ab/8fa097db361a1e8586535ae5073559e6229596b3489ec3ef2f5b38df8cb2/coverage-7.13.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:99acd4dfdfeb58e1937629eb1ab6ab0899b131f183ee5f23e0b5da5cba2fec74", size = 249652, upload-time = "2025-12-08T13:13:13.909Z" }, - { url = "https://files.pythonhosted.org/packages/90/3a/9bfd4de2ff191feb37ef9465855ca56a6f2f30a3bca172e474130731ac3d/coverage-7.13.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff45e0cd8451e293b63ced93161e189780baf444119391b3e7d25315060368a6", size = 252251, upload-time = "2025-12-08T13:13:15.553Z" }, - { url = "https://files.pythonhosted.org/packages/df/61/b5d8105f016e1b5874af0d7c67542da780ccd4a5f2244a433d3e20ceb1ad/coverage-7.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4f72a85316d8e13234cafe0a9f81b40418ad7a082792fa4165bd7d45d96066b", size = 253492, upload-time = "2025-12-08T13:13:16.849Z" }, - { url = "https://files.pythonhosted.org/packages/f3/b8/0fad449981803cc47a4694768b99823fb23632150743f9c83af329bb6090/coverage-7.13.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11c21557d0e0a5a38632cbbaca5f008723b26a89d70db6315523df6df77d6232", size = 249850, upload-time = "2025-12-08T13:13:18.142Z" }, - { url = "https://files.pythonhosted.org/packages/9a/e9/8d68337c3125014d918cf4327d5257553a710a2995a6a6de2ac77e5aa429/coverage-7.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76541dc8d53715fb4f7a3a06b34b0dc6846e3c69bc6204c55653a85dd6220971", size = 251633, upload-time = "2025-12-08T13:13:19.56Z" }, - { url = "https://files.pythonhosted.org/packages/55/14/d4112ab26b3a1bc4b3c1295d8452dcf399ed25be4cf649002fb3e64b2d93/coverage-7.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6e9e451dee940a86789134b6b0ffbe31c454ade3b849bb8a9d2cca2541a8e91d", size = 249586, upload-time = "2025-12-08T13:13:20.883Z" }, - { url = "https://files.pythonhosted.org/packages/2c/a9/22b0000186db663b0d82f86c2f1028099ae9ac202491685051e2a11a5218/coverage-7.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5c67dace46f361125e6b9cace8fe0b729ed8479f47e70c89b838d319375c8137", size = 249412, upload-time = "2025-12-08T13:13:22.22Z" }, - { url = "https://files.pythonhosted.org/packages/a1/2e/42d8e0d9e7527fba439acdc6ed24a2b97613b1dc85849b1dd935c2cffef0/coverage-7.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f59883c643cb19630500f57016f76cfdcd6845ca8c5b5ea1f6e17f74c8e5f511", size = 251191, upload-time = "2025-12-08T13:13:23.899Z" }, - { url = "https://files.pythonhosted.org/packages/a4/af/8c7af92b1377fd8860536aadd58745119252aaaa71a5213e5a8e8007a9f5/coverage-7.13.0-cp313-cp313-win32.whl", hash = "sha256:58632b187be6f0be500f553be41e277712baa278147ecb7559983c6d9faf7ae1", size = 220829, upload-time = "2025-12-08T13:13:25.182Z" }, - { url = "https://files.pythonhosted.org/packages/58/f9/725e8bf16f343d33cbe076c75dc8370262e194ff10072c0608b8e5cf33a3/coverage-7.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:73419b89f812f498aca53f757dd834919b48ce4799f9d5cad33ca0ae442bdb1a", size = 221640, upload-time = "2025-12-08T13:13:26.836Z" }, - { url = "https://files.pythonhosted.org/packages/8a/ff/e98311000aa6933cc79274e2b6b94a2fe0fe3434fca778eba82003675496/coverage-7.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:eb76670874fdd6091eedcc856128ee48c41a9bbbb9c3f1c7c3cf169290e3ffd6", size = 220269, upload-time = "2025-12-08T13:13:28.116Z" }, - { url = "https://files.pythonhosted.org/packages/cf/cf/bbaa2e1275b300343ea865f7d424cc0a2e2a1df6925a070b2b2d5d765330/coverage-7.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6e63ccc6e0ad8986386461c3c4b737540f20426e7ec932f42e030320896c311a", size = 218990, upload-time = "2025-12-08T13:13:29.463Z" }, - { url = "https://files.pythonhosted.org/packages/21/1d/82f0b3323b3d149d7672e7744c116e9c170f4957e0c42572f0366dbb4477/coverage-7.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:494f5459ffa1bd45e18558cd98710c36c0b8fbfa82a5eabcbe671d80ecffbfe8", size = 219340, upload-time = "2025-12-08T13:13:31.524Z" }, - { url = "https://files.pythonhosted.org/packages/fb/e3/fe3fd4702a3832a255f4d43013eacb0ef5fc155a5960ea9269d8696db28b/coverage-7.13.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:06cac81bf10f74034e055e903f5f946e3e26fc51c09fc9f584e4a1605d977053", size = 260638, upload-time = "2025-12-08T13:13:32.965Z" }, - { url = "https://files.pythonhosted.org/packages/ad/01/63186cb000307f2b4da463f72af9b85d380236965574c78e7e27680a2593/coverage-7.13.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f2ffc92b46ed6e6760f1d47a71e56b5664781bc68986dbd1836b2b70c0ce2071", size = 262705, upload-time = "2025-12-08T13:13:34.378Z" }, - { url = "https://files.pythonhosted.org/packages/7c/a1/c0dacef0cc865f2455d59eed3548573ce47ed603205ffd0735d1d78b5906/coverage-7.13.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0602f701057c6823e5db1b74530ce85f17c3c5be5c85fc042ac939cbd909426e", size = 265125, upload-time = "2025-12-08T13:13:35.73Z" }, - { url = "https://files.pythonhosted.org/packages/ef/92/82b99223628b61300bd382c205795533bed021505eab6dd86e11fb5d7925/coverage-7.13.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:25dc33618d45456ccb1d37bce44bc78cf269909aa14c4db2e03d63146a8a1493", size = 259844, upload-time = "2025-12-08T13:13:37.69Z" }, - { url = "https://files.pythonhosted.org/packages/cf/2c/89b0291ae4e6cd59ef042708e1c438e2290f8c31959a20055d8768349ee2/coverage-7.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:71936a8b3b977ddd0b694c28c6a34f4fff2e9dd201969a4ff5d5fc7742d614b0", size = 262700, upload-time = "2025-12-08T13:13:39.525Z" }, - { url = "https://files.pythonhosted.org/packages/bf/f9/a5f992efae1996245e796bae34ceb942b05db275e4b34222a9a40b9fbd3b/coverage-7.13.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:936bc20503ce24770c71938d1369461f0c5320830800933bc3956e2a4ded930e", size = 260321, upload-time = "2025-12-08T13:13:41.172Z" }, - { url = "https://files.pythonhosted.org/packages/4c/89/a29f5d98c64fedbe32e2ac3c227fbf78edc01cc7572eee17d61024d89889/coverage-7.13.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:af0a583efaacc52ae2521f8d7910aff65cdb093091d76291ac5820d5e947fc1c", size = 259222, upload-time = "2025-12-08T13:13:43.282Z" }, - { url = "https://files.pythonhosted.org/packages/b3/c3/940fe447aae302a6701ee51e53af7e08b86ff6eed7631e5740c157ee22b9/coverage-7.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f1c23e24a7000da892a312fb17e33c5f94f8b001de44b7cf8ba2e36fbd15859e", size = 261411, upload-time = "2025-12-08T13:13:44.72Z" }, - { url = "https://files.pythonhosted.org/packages/eb/31/12a4aec689cb942a89129587860ed4d0fd522d5fda81237147fde554b8ae/coverage-7.13.0-cp313-cp313t-win32.whl", hash = "sha256:5f8a0297355e652001015e93be345ee54393e45dc3050af4a0475c5a2b767d46", size = 221505, upload-time = "2025-12-08T13:13:46.332Z" }, - { url = "https://files.pythonhosted.org/packages/65/8c/3b5fe3259d863572d2b0827642c50c3855d26b3aefe80bdc9eba1f0af3b0/coverage-7.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6abb3a4c52f05e08460bd9acf04fec027f8718ecaa0d09c40ffbc3fbd70ecc39", size = 222569, upload-time = "2025-12-08T13:13:47.79Z" }, - { url = "https://files.pythonhosted.org/packages/b0/39/f71fa8316a96ac72fc3908839df651e8eccee650001a17f2c78cdb355624/coverage-7.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3ad968d1e3aa6ce5be295ab5fe3ae1bf5bb4769d0f98a80a0252d543a2ef2e9e", size = 220841, upload-time = "2025-12-08T13:13:49.243Z" }, - { url = "https://files.pythonhosted.org/packages/f8/4b/9b54bedda55421449811dcd5263a2798a63f48896c24dfb92b0f1b0845bd/coverage-7.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:453b7ec753cf5e4356e14fe858064e5520c460d3bbbcb9c35e55c0d21155c256", size = 218343, upload-time = "2025-12-08T13:13:50.811Z" }, - { url = "https://files.pythonhosted.org/packages/59/df/c3a1f34d4bba2e592c8979f924da4d3d4598b0df2392fbddb7761258e3dc/coverage-7.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af827b7cbb303e1befa6c4f94fd2bf72f108089cfa0f8abab8f4ca553cf5ca5a", size = 218672, upload-time = "2025-12-08T13:13:52.284Z" }, - { url = "https://files.pythonhosted.org/packages/07/62/eec0659e47857698645ff4e6ad02e30186eb8afd65214fd43f02a76537cb/coverage-7.13.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9987a9e4f8197a1000280f7cc089e3ea2c8b3c0a64d750537809879a7b4ceaf9", size = 249715, upload-time = "2025-12-08T13:13:53.791Z" }, - { url = "https://files.pythonhosted.org/packages/23/2d/3c7ff8b2e0e634c1f58d095f071f52ed3c23ff25be524b0ccae8b71f99f8/coverage-7.13.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3188936845cd0cb114fa6a51842a304cdbac2958145d03be2377ec41eb285d19", size = 252225, upload-time = "2025-12-08T13:13:55.274Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ac/fb03b469d20e9c9a81093575003f959cf91a4a517b783aab090e4538764b/coverage-7.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2bdb3babb74079f021696cb46b8bb5f5661165c385d3a238712b031a12355be", size = 253559, upload-time = "2025-12-08T13:13:57.161Z" }, - { url = "https://files.pythonhosted.org/packages/29/62/14afa9e792383c66cc0a3b872a06ded6e4ed1079c7d35de274f11d27064e/coverage-7.13.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7464663eaca6adba4175f6c19354feea61ebbdd735563a03d1e472c7072d27bb", size = 249724, upload-time = "2025-12-08T13:13:58.692Z" }, - { url = "https://files.pythonhosted.org/packages/31/b7/333f3dab2939070613696ab3ee91738950f0467778c6e5a5052e840646b7/coverage-7.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8069e831f205d2ff1f3d355e82f511eb7c5522d7d413f5db5756b772ec8697f8", size = 251582, upload-time = "2025-12-08T13:14:00.642Z" }, - { url = "https://files.pythonhosted.org/packages/81/cb/69162bda9381f39b2287265d7e29ee770f7c27c19f470164350a38318764/coverage-7.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6fb2d5d272341565f08e962cce14cdf843a08ac43bd621783527adb06b089c4b", size = 249538, upload-time = "2025-12-08T13:14:02.556Z" }, - { url = "https://files.pythonhosted.org/packages/e0/76/350387b56a30f4970abe32b90b2a434f87d29f8b7d4ae40d2e8a85aacfb3/coverage-7.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5e70f92ef89bac1ac8a99b3324923b4749f008fdbd7aa9cb35e01d7a284a04f9", size = 249349, upload-time = "2025-12-08T13:14:04.015Z" }, - { url = "https://files.pythonhosted.org/packages/86/0d/7f6c42b8d59f4c7e43ea3059f573c0dcfed98ba46eb43c68c69e52ae095c/coverage-7.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4b5de7d4583e60d5fd246dd57fcd3a8aa23c6e118a8c72b38adf666ba8e7e927", size = 251011, upload-time = "2025-12-08T13:14:05.505Z" }, - { url = "https://files.pythonhosted.org/packages/d7/f1/4bb2dff379721bb0b5c649d5c5eaf438462cad824acf32eb1b7ca0c7078e/coverage-7.13.0-cp314-cp314-win32.whl", hash = "sha256:a6c6e16b663be828a8f0b6c5027d36471d4a9f90d28444aa4ced4d48d7d6ae8f", size = 221091, upload-time = "2025-12-08T13:14:07.127Z" }, - { url = "https://files.pythonhosted.org/packages/ba/44/c239da52f373ce379c194b0ee3bcc121020e397242b85f99e0afc8615066/coverage-7.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:0900872f2fdb3ee5646b557918d02279dc3af3dfb39029ac4e945458b13f73bc", size = 221904, upload-time = "2025-12-08T13:14:08.542Z" }, - { url = "https://files.pythonhosted.org/packages/89/1f/b9f04016d2a29c2e4a0307baefefad1a4ec5724946a2b3e482690486cade/coverage-7.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:3a10260e6a152e5f03f26db4a407c4c62d3830b9af9b7c0450b183615f05d43b", size = 220480, upload-time = "2025-12-08T13:14:10.958Z" }, - { url = "https://files.pythonhosted.org/packages/16/d4/364a1439766c8e8647860584171c36010ca3226e6e45b1753b1b249c5161/coverage-7.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9097818b6cc1cfb5f174e3263eba4a62a17683bcfe5c4b5d07f4c97fa51fbf28", size = 219074, upload-time = "2025-12-08T13:14:13.345Z" }, - { url = "https://files.pythonhosted.org/packages/ce/f4/71ba8be63351e099911051b2089662c03d5671437a0ec2171823c8e03bec/coverage-7.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0018f73dfb4301a89292c73be6ba5f58722ff79f51593352759c1790ded1cabe", size = 219342, upload-time = "2025-12-08T13:14:15.02Z" }, - { url = "https://files.pythonhosted.org/packages/5e/25/127d8ed03d7711a387d96f132589057213e3aef7475afdaa303412463f22/coverage-7.13.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:166ad2a22ee770f5656e1257703139d3533b4a0b6909af67c6b4a3adc1c98657", size = 260713, upload-time = "2025-12-08T13:14:16.907Z" }, - { url = "https://files.pythonhosted.org/packages/fd/db/559fbb6def07d25b2243663b46ba9eb5a3c6586c0c6f4e62980a68f0ee1c/coverage-7.13.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f6aaef16d65d1787280943f1c8718dc32e9cf141014e4634d64446702d26e0ff", size = 262825, upload-time = "2025-12-08T13:14:18.68Z" }, - { url = "https://files.pythonhosted.org/packages/37/99/6ee5bf7eff884766edb43bd8736b5e1c5144d0fe47498c3779326fe75a35/coverage-7.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e999e2dcc094002d6e2c7bbc1fb85b58ba4f465a760a8014d97619330cdbbbf3", size = 265233, upload-time = "2025-12-08T13:14:20.55Z" }, - { url = "https://files.pythonhosted.org/packages/d8/90/92f18fe0356ea69e1f98f688ed80cec39f44e9f09a1f26a1bbf017cc67f2/coverage-7.13.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:00c3d22cf6fb1cf3bf662aaaa4e563be8243a5ed2630339069799835a9cc7f9b", size = 259779, upload-time = "2025-12-08T13:14:22.367Z" }, - { url = "https://files.pythonhosted.org/packages/90/5d/b312a8b45b37a42ea7d27d7d3ff98ade3a6c892dd48d1d503e773503373f/coverage-7.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22ccfe8d9bb0d6134892cbe1262493a8c70d736b9df930f3f3afae0fe3ac924d", size = 262700, upload-time = "2025-12-08T13:14:24.309Z" }, - { url = "https://files.pythonhosted.org/packages/63/f8/b1d0de5c39351eb71c366f872376d09386640840a2e09b0d03973d791e20/coverage-7.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9372dff5ea15930fea0445eaf37bbbafbc771a49e70c0aeed8b4e2c2614cc00e", size = 260302, upload-time = "2025-12-08T13:14:26.068Z" }, - { url = "https://files.pythonhosted.org/packages/aa/7c/d42f4435bc40c55558b3109a39e2d456cddcec37434f62a1f1230991667a/coverage-7.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:69ac2c492918c2461bc6ace42d0479638e60719f2a4ef3f0815fa2df88e9f940", size = 259136, upload-time = "2025-12-08T13:14:27.604Z" }, - { url = "https://files.pythonhosted.org/packages/b8/d3/23413241dc04d47cfe19b9a65b32a2edd67ecd0b817400c2843ebc58c847/coverage-7.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:739c6c051a7540608d097b8e13c76cfa85263ced467168dc6b477bae3df7d0e2", size = 261467, upload-time = "2025-12-08T13:14:29.09Z" }, - { url = "https://files.pythonhosted.org/packages/13/e6/6e063174500eee216b96272c0d1847bf215926786f85c2bd024cf4d02d2f/coverage-7.13.0-cp314-cp314t-win32.whl", hash = "sha256:fe81055d8c6c9de76d60c94ddea73c290b416e061d40d542b24a5871bad498b7", size = 221875, upload-time = "2025-12-08T13:14:31.106Z" }, - { url = "https://files.pythonhosted.org/packages/3b/46/f4fb293e4cbe3620e3ac2a3e8fd566ed33affb5861a9b20e3dd6c1896cbc/coverage-7.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:445badb539005283825959ac9fa4a28f712c214b65af3a2c464f1adc90f5fcbc", size = 222982, upload-time = "2025-12-08T13:14:33.1Z" }, - { url = "https://files.pythonhosted.org/packages/68/62/5b3b9018215ed9733fbd1ae3b2ed75c5de62c3b55377a52cae732e1b7805/coverage-7.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:de7f6748b890708578fc4b7bb967d810aeb6fcc9bff4bb77dbca77dab2f9df6a", size = 221016, upload-time = "2025-12-08T13:14:34.601Z" }, - { url = "https://files.pythonhosted.org/packages/8d/4c/1968f32fb9a2604645827e11ff84a31e59d532e01995f904723b4f5328b3/coverage-7.13.0-py3-none-any.whl", hash = "sha256:850d2998f380b1e266459ca5b47bc9e7daf9af1d070f66317972f382d46f1904", size = 210068, upload-time = "2025-12-08T13:14:36.236Z" }, -] - -[package.optional-dependencies] -toml = [ - { name = "tomli", marker = "python_full_version <= '3.11'" }, -] - -[[package]] -name = "exceptiongroup" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, -] - -[[package]] -name = "iniconfig" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, -] - -[[package]] -name = "librt" -version = "0.7.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/93/e4/b59bdf1197fdf9888452ea4d2048cdad61aef85eb83e99dc52551d7fdc04/librt-0.7.4.tar.gz", hash = "sha256:3871af56c59864d5fd21d1ac001eb2fb3b140d52ba0454720f2e4a19812404ba", size = 145862, upload-time = "2025-12-15T16:52:43.862Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/06/1e/3e61dff6c07a3b400fe907d3164b92b3b3023ef86eac1ee236869dc276f7/librt-0.7.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dc300cb5a5a01947b1ee8099233156fdccd5001739e5f596ecfbc0dab07b5a3b", size = 54708, upload-time = "2025-12-15T16:51:03.752Z" }, - { url = "https://files.pythonhosted.org/packages/87/98/ab2428b0a80d0fd67decaeea84a5ec920e3dd4d95ecfd074c71f51bd7315/librt-0.7.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ee8d3323d921e0f6919918a97f9b5445a7dfe647270b2629ec1008aa676c0bc0", size = 56656, upload-time = "2025-12-15T16:51:05.038Z" }, - { url = "https://files.pythonhosted.org/packages/c1/ce/de1fad3a16e4fb5b6605bd6cbe6d0e5207cc8eca58993835749a1da0812b/librt-0.7.4-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:95cb80854a355b284c55f79674f6187cc9574df4dc362524e0cce98c89ee8331", size = 161024, upload-time = "2025-12-15T16:51:06.31Z" }, - { url = "https://files.pythonhosted.org/packages/88/00/ddfcdc1147dd7fb68321d7b064b12f0b9101d85f466a46006f86096fde8d/librt-0.7.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ca1caedf8331d8ad6027f93b52d68ed8f8009f5c420c246a46fe9d3be06be0f", size = 169529, upload-time = "2025-12-15T16:51:07.907Z" }, - { url = "https://files.pythonhosted.org/packages/dd/b3/915702c7077df2483b015030d1979404474f490fe9a071e9576f7b26fef6/librt-0.7.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2a6f1236151e6fe1da289351b5b5bce49651c91554ecc7b70a947bced6fe212", size = 183270, upload-time = "2025-12-15T16:51:09.164Z" }, - { url = "https://files.pythonhosted.org/packages/45/19/ab2f217e8ec509fca4ea9e2e5022b9f72c1a7b7195f5a5770d299df807ea/librt-0.7.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7766b57aeebaf3f1dac14fdd4a75c9a61f2ed56d8ebeefe4189db1cb9d2a3783", size = 179038, upload-time = "2025-12-15T16:51:10.538Z" }, - { url = "https://files.pythonhosted.org/packages/10/1c/d40851d187662cf50312ebbc0b277c7478dd78dbaaf5ee94056f1d7f2f83/librt-0.7.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:1c4c89fb01157dd0a3bfe9e75cd6253b0a1678922befcd664eca0772a4c6c979", size = 173502, upload-time = "2025-12-15T16:51:11.888Z" }, - { url = "https://files.pythonhosted.org/packages/07/52/d5880835c772b22c38db18660420fa6901fd9e9a433b65f0ba9b0f4da764/librt-0.7.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f7fa8beef580091c02b4fd26542de046b2abfe0aaefa02e8bcf68acb7618f2b3", size = 193570, upload-time = "2025-12-15T16:51:13.168Z" }, - { url = "https://files.pythonhosted.org/packages/f1/35/22d3c424b82f86ce019c0addadf001d459dfac8036aecc07fadc5c541053/librt-0.7.4-cp310-cp310-win32.whl", hash = "sha256:543c42fa242faae0466fe72d297976f3c710a357a219b1efde3a0539a68a6997", size = 42596, upload-time = "2025-12-15T16:51:14.422Z" }, - { url = "https://files.pythonhosted.org/packages/95/b1/e7c316ac5fe60ac1fdfe515198087205220803c4cf923ee63e1cb8380b17/librt-0.7.4-cp310-cp310-win_amd64.whl", hash = "sha256:25cc40d8eb63f0a7ea4c8f49f524989b9df901969cb860a2bc0e4bad4b8cb8a8", size = 48972, upload-time = "2025-12-15T16:51:15.516Z" }, - { url = "https://files.pythonhosted.org/packages/84/64/44089b12d8b4714a7f0e2f33fb19285ba87702d4be0829f20b36ebeeee07/librt-0.7.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3485b9bb7dfa66167d5500ffdafdc35415b45f0da06c75eb7df131f3357b174a", size = 54709, upload-time = "2025-12-15T16:51:16.699Z" }, - { url = "https://files.pythonhosted.org/packages/26/ef/6fa39fb5f37002f7d25e0da4f24d41b457582beea9369eeb7e9e73db5508/librt-0.7.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:188b4b1a770f7f95ea035d5bbb9d7367248fc9d12321deef78a269ebf46a5729", size = 56663, upload-time = "2025-12-15T16:51:17.856Z" }, - { url = "https://files.pythonhosted.org/packages/9d/e4/cbaca170a13bee2469c90df9e47108610b4422c453aea1aec1779ac36c24/librt-0.7.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1b668b1c840183e4e38ed5a99f62fac44c3a3eef16870f7f17cfdfb8b47550ed", size = 161703, upload-time = "2025-12-15T16:51:19.421Z" }, - { url = "https://files.pythonhosted.org/packages/d0/32/0b2296f9cc7e693ab0d0835e355863512e5eac90450c412777bd699c76ae/librt-0.7.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0e8f864b521f6cfedb314d171630f827efee08f5c3462bcbc2244ab8e1768cd6", size = 171027, upload-time = "2025-12-15T16:51:20.721Z" }, - { url = "https://files.pythonhosted.org/packages/d8/33/c70b6d40f7342716e5f1353c8da92d9e32708a18cbfa44897a93ec2bf879/librt-0.7.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df7c9def4fc619a9c2ab402d73a0c5b53899abe090e0100323b13ccb5a3dd82", size = 184700, upload-time = "2025-12-15T16:51:22.272Z" }, - { url = "https://files.pythonhosted.org/packages/e4/c8/555c405155da210e4c4113a879d378f54f850dbc7b794e847750a8fadd43/librt-0.7.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f79bc3595b6ed159a1bf0cdc70ed6ebec393a874565cab7088a219cca14da727", size = 180719, upload-time = "2025-12-15T16:51:23.561Z" }, - { url = "https://files.pythonhosted.org/packages/6b/88/34dc1f1461c5613d1b73f0ecafc5316cc50adcc1b334435985b752ed53e5/librt-0.7.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:77772a4b8b5f77d47d883846928c36d730b6e612a6388c74cba33ad9eb149c11", size = 174535, upload-time = "2025-12-15T16:51:25.031Z" }, - { url = "https://files.pythonhosted.org/packages/b6/5a/f3fafe80a221626bcedfa9fe5abbf5f04070989d44782f579b2d5920d6d0/librt-0.7.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:064a286e6ab0b4c900e228ab4fa9cb3811b4b83d3e0cc5cd816b2d0f548cb61c", size = 195236, upload-time = "2025-12-15T16:51:26.328Z" }, - { url = "https://files.pythonhosted.org/packages/d8/77/5c048d471ce17f4c3a6e08419be19add4d291e2f7067b877437d482622ac/librt-0.7.4-cp311-cp311-win32.whl", hash = "sha256:42da201c47c77b6cc91fc17e0e2b330154428d35d6024f3278aa2683e7e2daf2", size = 42930, upload-time = "2025-12-15T16:51:27.853Z" }, - { url = "https://files.pythonhosted.org/packages/fb/3b/514a86305a12c3d9eac03e424b07cd312c7343a9f8a52719aa079590a552/librt-0.7.4-cp311-cp311-win_amd64.whl", hash = "sha256:d31acb5886c16ae1711741f22504195af46edec8315fe69b77e477682a87a83e", size = 49240, upload-time = "2025-12-15T16:51:29.037Z" }, - { url = "https://files.pythonhosted.org/packages/ba/01/3b7b1914f565926b780a734fac6e9a4d2c7aefe41f4e89357d73697a9457/librt-0.7.4-cp311-cp311-win_arm64.whl", hash = "sha256:114722f35093da080a333b3834fff04ef43147577ed99dd4db574b03a5f7d170", size = 42613, upload-time = "2025-12-15T16:51:30.194Z" }, - { url = "https://files.pythonhosted.org/packages/f3/e7/b805d868d21f425b7e76a0ea71a2700290f2266a4f3c8357fcf73efc36aa/librt-0.7.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7dd3b5c37e0fb6666c27cf4e2c88ae43da904f2155c4cfc1e5a2fdce3b9fcf92", size = 55688, upload-time = "2025-12-15T16:51:31.571Z" }, - { url = "https://files.pythonhosted.org/packages/59/5e/69a2b02e62a14cfd5bfd9f1e9adea294d5bcfeea219c7555730e5d068ee4/librt-0.7.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9c5de1928c486201b23ed0cc4ac92e6e07be5cd7f3abc57c88a9cf4f0f32108", size = 57141, upload-time = "2025-12-15T16:51:32.714Z" }, - { url = "https://files.pythonhosted.org/packages/6e/6b/05dba608aae1272b8ea5ff8ef12c47a4a099a04d1e00e28a94687261d403/librt-0.7.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:078ae52ffb3f036396cc4aed558e5b61faedd504a3c1f62b8ae34bf95ae39d94", size = 165322, upload-time = "2025-12-15T16:51:33.986Z" }, - { url = "https://files.pythonhosted.org/packages/8f/bc/199533d3fc04a4cda8d7776ee0d79955ab0c64c79ca079366fbc2617e680/librt-0.7.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce58420e25097b2fc201aef9b9f6d65df1eb8438e51154e1a7feb8847e4a55ab", size = 174216, upload-time = "2025-12-15T16:51:35.384Z" }, - { url = "https://files.pythonhosted.org/packages/62/ec/09239b912a45a8ed117cb4a6616d9ff508f5d3131bd84329bf2f8d6564f1/librt-0.7.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b719c8730c02a606dc0e8413287e8e94ac2d32a51153b300baf1f62347858fba", size = 189005, upload-time = "2025-12-15T16:51:36.687Z" }, - { url = "https://files.pythonhosted.org/packages/46/2e/e188313d54c02f5b0580dd31476bb4b0177514ff8d2be9f58d4a6dc3a7ba/librt-0.7.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3749ef74c170809e6dee68addec9d2458700a8de703de081c888e92a8b015cf9", size = 183960, upload-time = "2025-12-15T16:51:37.977Z" }, - { url = "https://files.pythonhosted.org/packages/eb/84/f1d568d254518463d879161d3737b784137d236075215e56c7c9be191cee/librt-0.7.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b35c63f557653c05b5b1b6559a074dbabe0afee28ee2a05b6c9ba21ad0d16a74", size = 177609, upload-time = "2025-12-15T16:51:40.584Z" }, - { url = "https://files.pythonhosted.org/packages/5d/43/060bbc1c002f0d757c33a1afe6bf6a565f947a04841139508fc7cef6c08b/librt-0.7.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1ef704e01cb6ad39ad7af668d51677557ca7e5d377663286f0ee1b6b27c28e5f", size = 199269, upload-time = "2025-12-15T16:51:41.879Z" }, - { url = "https://files.pythonhosted.org/packages/ff/7f/708f8f02d8012ee9f366c07ea6a92882f48bd06cc1ff16a35e13d0fbfb08/librt-0.7.4-cp312-cp312-win32.whl", hash = "sha256:c66c2b245926ec15188aead25d395091cb5c9df008d3b3207268cd65557d6286", size = 43186, upload-time = "2025-12-15T16:51:43.149Z" }, - { url = "https://files.pythonhosted.org/packages/f1/a5/4e051b061c8b2509be31b2c7ad4682090502c0a8b6406edcf8c6b4fe1ef7/librt-0.7.4-cp312-cp312-win_amd64.whl", hash = "sha256:71a56f4671f7ff723451f26a6131754d7c1809e04e22ebfbac1db8c9e6767a20", size = 49455, upload-time = "2025-12-15T16:51:44.336Z" }, - { url = "https://files.pythonhosted.org/packages/d0/d2/90d84e9f919224a3c1f393af1636d8638f54925fdc6cd5ee47f1548461e5/librt-0.7.4-cp312-cp312-win_arm64.whl", hash = "sha256:419eea245e7ec0fe664eb7e85e7ff97dcdb2513ca4f6b45a8ec4a3346904f95a", size = 42828, upload-time = "2025-12-15T16:51:45.498Z" }, - { url = "https://files.pythonhosted.org/packages/fe/4d/46a53ccfbb39fd0b493fd4496eb76f3ebc15bb3e45d8c2e695a27587edf5/librt-0.7.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d44a1b1ba44cbd2fc3cb77992bef6d6fdb1028849824e1dd5e4d746e1f7f7f0b", size = 55745, upload-time = "2025-12-15T16:51:46.636Z" }, - { url = "https://files.pythonhosted.org/packages/7f/2b/3ac7f5212b1828bf4f979cf87f547db948d3e28421d7a430d4db23346ce4/librt-0.7.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c9cab4b3de1f55e6c30a84c8cee20e4d3b2476f4d547256694a1b0163da4fe32", size = 57166, upload-time = "2025-12-15T16:51:48.219Z" }, - { url = "https://files.pythonhosted.org/packages/e8/99/6523509097cbe25f363795f0c0d1c6a3746e30c2994e25b5aefdab119b21/librt-0.7.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2857c875f1edd1feef3c371fbf830a61b632fb4d1e57160bb1e6a3206e6abe67", size = 165833, upload-time = "2025-12-15T16:51:49.443Z" }, - { url = "https://files.pythonhosted.org/packages/fe/35/323611e59f8fe032649b4fb7e77f746f96eb7588fcbb31af26bae9630571/librt-0.7.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b370a77be0a16e1ad0270822c12c21462dc40496e891d3b0caf1617c8cc57e20", size = 174818, upload-time = "2025-12-15T16:51:51.015Z" }, - { url = "https://files.pythonhosted.org/packages/41/e6/40fb2bb21616c6e06b6a64022802228066e9a31618f493e03f6b9661548a/librt-0.7.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d05acd46b9a52087bfc50c59dfdf96a2c480a601e8898a44821c7fd676598f74", size = 189607, upload-time = "2025-12-15T16:51:52.671Z" }, - { url = "https://files.pythonhosted.org/packages/32/48/1b47c7d5d28b775941e739ed2bfe564b091c49201b9503514d69e4ed96d7/librt-0.7.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:70969229cb23d9c1a80e14225838d56e464dc71fa34c8342c954fc50e7516dee", size = 184585, upload-time = "2025-12-15T16:51:54.027Z" }, - { url = "https://files.pythonhosted.org/packages/75/a6/ee135dfb5d3b54d5d9001dbe483806229c6beac3ee2ba1092582b7efeb1b/librt-0.7.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4450c354b89dbb266730893862dbff06006c9ed5b06b6016d529b2bf644fc681", size = 178249, upload-time = "2025-12-15T16:51:55.248Z" }, - { url = "https://files.pythonhosted.org/packages/04/87/d5b84ec997338be26af982bcd6679be0c1db9a32faadab1cf4bb24f9e992/librt-0.7.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:adefe0d48ad35b90b6f361f6ff5a1bd95af80c17d18619c093c60a20e7a5b60c", size = 199851, upload-time = "2025-12-15T16:51:56.933Z" }, - { url = "https://files.pythonhosted.org/packages/86/63/ba1333bf48306fe398e3392a7427ce527f81b0b79d0d91618c4610ce9d15/librt-0.7.4-cp313-cp313-win32.whl", hash = "sha256:21ea710e96c1e050635700695095962a22ea420d4b3755a25e4909f2172b4ff2", size = 43249, upload-time = "2025-12-15T16:51:58.498Z" }, - { url = "https://files.pythonhosted.org/packages/f9/8a/de2c6df06cdfa9308c080e6b060fe192790b6a48a47320b215e860f0e98c/librt-0.7.4-cp313-cp313-win_amd64.whl", hash = "sha256:772e18696cf5a64afee908662fbcb1f907460ddc851336ee3a848ef7684c8e1e", size = 49417, upload-time = "2025-12-15T16:51:59.618Z" }, - { url = "https://files.pythonhosted.org/packages/31/66/8ee0949efc389691381ed686185e43536c20e7ad880c122dd1f31e65c658/librt-0.7.4-cp313-cp313-win_arm64.whl", hash = "sha256:52e34c6af84e12921748c8354aa6acf1912ca98ba60cdaa6920e34793f1a0788", size = 42824, upload-time = "2025-12-15T16:52:00.784Z" }, - { url = "https://files.pythonhosted.org/packages/74/81/6921e65c8708eb6636bbf383aa77e6c7dad33a598ed3b50c313306a2da9d/librt-0.7.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4f1ee004942eaaed6e06c087d93ebc1c67e9a293e5f6b9b5da558df6bf23dc5d", size = 55191, upload-time = "2025-12-15T16:52:01.97Z" }, - { url = "https://files.pythonhosted.org/packages/0d/d6/3eb864af8a8de8b39cc8dd2e9ded1823979a27795d72c4eea0afa8c26c9f/librt-0.7.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d854c6dc0f689bad7ed452d2a3ecff58029d80612d336a45b62c35e917f42d23", size = 56898, upload-time = "2025-12-15T16:52:03.356Z" }, - { url = "https://files.pythonhosted.org/packages/49/bc/b1d4c0711fdf79646225d576faee8747b8528a6ec1ceb6accfd89ade7102/librt-0.7.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a4f7339d9e445280f23d63dea842c0c77379c4a47471c538fc8feedab9d8d063", size = 163725, upload-time = "2025-12-15T16:52:04.572Z" }, - { url = "https://files.pythonhosted.org/packages/2c/08/61c41cd8f0a6a41fc99ea78a2205b88187e45ba9800792410ed62f033584/librt-0.7.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39003fc73f925e684f8521b2dbf34f61a5deb8a20a15dcf53e0d823190ce8848", size = 172469, upload-time = "2025-12-15T16:52:05.863Z" }, - { url = "https://files.pythonhosted.org/packages/8b/c7/4ee18b4d57f01444230bc18cf59103aeab8f8c0f45e84e0e540094df1df1/librt-0.7.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bb15ee29d95875ad697d449fe6071b67f730f15a6961913a2b0205015ca0843", size = 186804, upload-time = "2025-12-15T16:52:07.192Z" }, - { url = "https://files.pythonhosted.org/packages/a1/af/009e8ba3fbf830c936842da048eda1b34b99329f402e49d88fafff6525d1/librt-0.7.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:02a69369862099e37d00765583052a99d6a68af7e19b887e1b78fee0146b755a", size = 181807, upload-time = "2025-12-15T16:52:08.554Z" }, - { url = "https://files.pythonhosted.org/packages/85/26/51ae25f813656a8b117c27a974f25e8c1e90abcd5a791ac685bf5b489a1b/librt-0.7.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ec72342cc4d62f38b25a94e28b9efefce41839aecdecf5e9627473ed04b7be16", size = 175595, upload-time = "2025-12-15T16:52:10.186Z" }, - { url = "https://files.pythonhosted.org/packages/48/93/36d6c71f830305f88996b15c8e017aa8d1e03e2e947b40b55bbf1a34cf24/librt-0.7.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:776dbb9bfa0fc5ce64234b446995d8d9f04badf64f544ca036bd6cff6f0732ce", size = 196504, upload-time = "2025-12-15T16:52:11.472Z" }, - { url = "https://files.pythonhosted.org/packages/08/11/8299e70862bb9d704735bf132c6be09c17b00fbc7cda0429a9df222fdc1b/librt-0.7.4-cp314-cp314-win32.whl", hash = "sha256:0f8cac84196d0ffcadf8469d9ded4d4e3a8b1c666095c2a291e22bf58e1e8a9f", size = 39738, upload-time = "2025-12-15T16:52:12.962Z" }, - { url = "https://files.pythonhosted.org/packages/54/d5/656b0126e4e0f8e2725cd2d2a1ec40f71f37f6f03f135a26b663c0e1a737/librt-0.7.4-cp314-cp314-win_amd64.whl", hash = "sha256:037f5cb6fe5abe23f1dc058054d50e9699fcc90d0677eee4e4f74a8677636a1a", size = 45976, upload-time = "2025-12-15T16:52:14.441Z" }, - { url = "https://files.pythonhosted.org/packages/60/86/465ff07b75c1067da8fa7f02913c4ead096ef106cfac97a977f763783bfb/librt-0.7.4-cp314-cp314-win_arm64.whl", hash = "sha256:a5deebb53d7a4d7e2e758a96befcd8edaaca0633ae71857995a0f16033289e44", size = 39073, upload-time = "2025-12-15T16:52:15.621Z" }, - { url = "https://files.pythonhosted.org/packages/b3/a0/24941f85960774a80d4b3c2aec651d7d980466da8101cae89e8b032a3e21/librt-0.7.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b4c25312c7f4e6ab35ab16211bdf819e6e4eddcba3b2ea632fb51c9a2a97e105", size = 57369, upload-time = "2025-12-15T16:52:16.782Z" }, - { url = "https://files.pythonhosted.org/packages/77/a0/ddb259cae86ab415786c1547d0fe1b40f04a7b089f564fd5c0242a3fafb2/librt-0.7.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:618b7459bb392bdf373f2327e477597fff8f9e6a1878fffc1b711c013d1b0da4", size = 59230, upload-time = "2025-12-15T16:52:18.259Z" }, - { url = "https://files.pythonhosted.org/packages/31/11/77823cb530ab8a0c6fac848ac65b745be446f6f301753b8990e8809080c9/librt-0.7.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1437c3f72a30c7047f16fd3e972ea58b90172c3c6ca309645c1c68984f05526a", size = 183869, upload-time = "2025-12-15T16:52:19.457Z" }, - { url = "https://files.pythonhosted.org/packages/a4/ce/157db3614cf3034b3f702ae5ba4fefda4686f11eea4b7b96542324a7a0e7/librt-0.7.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c96cb76f055b33308f6858b9b594618f1b46e147a4d03a4d7f0c449e304b9b95", size = 194606, upload-time = "2025-12-15T16:52:20.795Z" }, - { url = "https://files.pythonhosted.org/packages/30/ef/6ec4c7e3d6490f69a4fd2803516fa5334a848a4173eac26d8ee6507bff6e/librt-0.7.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28f990e6821204f516d09dc39966ef8b84556ffd648d5926c9a3f681e8de8906", size = 206776, upload-time = "2025-12-15T16:52:22.229Z" }, - { url = "https://files.pythonhosted.org/packages/ad/22/750b37bf549f60a4782ab80e9d1e9c44981374ab79a7ea68670159905918/librt-0.7.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc4aebecc79781a1b77d7d4e7d9fe080385a439e198d993b557b60f9117addaf", size = 203205, upload-time = "2025-12-15T16:52:23.603Z" }, - { url = "https://files.pythonhosted.org/packages/7a/87/2e8a0f584412a93df5faad46c5fa0a6825fdb5eba2ce482074b114877f44/librt-0.7.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:022cc673e69283a42621dd453e2407cf1647e77f8bd857d7ad7499901e62376f", size = 196696, upload-time = "2025-12-15T16:52:24.951Z" }, - { url = "https://files.pythonhosted.org/packages/e5/ca/7bf78fa950e43b564b7de52ceeb477fb211a11f5733227efa1591d05a307/librt-0.7.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2b3ca211ae8ea540569e9c513da052699b7b06928dcda61247cb4f318122bdb5", size = 217191, upload-time = "2025-12-15T16:52:26.194Z" }, - { url = "https://files.pythonhosted.org/packages/d6/49/3732b0e8424ae35ad5c3166d9dd5bcdae43ce98775e0867a716ff5868064/librt-0.7.4-cp314-cp314t-win32.whl", hash = "sha256:8a461f6456981d8c8e971ff5a55f2e34f4e60871e665d2f5fde23ee74dea4eeb", size = 40276, upload-time = "2025-12-15T16:52:27.54Z" }, - { url = "https://files.pythonhosted.org/packages/35/d6/d8823e01bd069934525fddb343189c008b39828a429b473fb20d67d5cd36/librt-0.7.4-cp314-cp314t-win_amd64.whl", hash = "sha256:721a7b125a817d60bf4924e1eec2a7867bfcf64cfc333045de1df7a0629e4481", size = 46772, upload-time = "2025-12-15T16:52:28.653Z" }, - { url = "https://files.pythonhosted.org/packages/36/e9/a0aa60f5322814dd084a89614e9e31139702e342f8459ad8af1984a18168/librt-0.7.4-cp314-cp314t-win_arm64.whl", hash = "sha256:76b2ba71265c0102d11458879b4d53ccd0b32b0164d14deb8d2b598a018e502f", size = 39724, upload-time = "2025-12-15T16:52:29.836Z" }, -] - -[[package]] -name = "macholib" -version = "1.16.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "altgraph" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/d1/a9f36f8ecdf0fb7c9b1e78c8d7af12b8c8754e74851ac7b94a8305540fc7/macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea", size = 38117, upload-time = "2025-11-22T08:28:36.939Z" }, -] - -[[package]] -name = "markdown-it-py" -version = "4.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mdurl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, -] - -[[package]] -name = "mdurl" -version = "0.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, -] - -[[package]] -name = "mypy" -version = "1.19.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, - { name = "mypy-extensions" }, - { name = "pathspec" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/63/e499890d8e39b1ff2df4c0c6ce5d371b6844ee22b8250687a99fd2f657a8/mypy-1.19.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5f05aa3d375b385734388e844bc01733bd33c644ab48e9684faa54e5389775ec", size = 13101333, upload-time = "2025-12-15T05:03:03.28Z" }, - { url = "https://files.pythonhosted.org/packages/72/4b/095626fc136fba96effc4fd4a82b41d688ab92124f8c4f7564bffe5cf1b0/mypy-1.19.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:022ea7279374af1a5d78dfcab853fe6a536eebfda4b59deab53cd21f6cd9f00b", size = 12164102, upload-time = "2025-12-15T05:02:33.611Z" }, - { url = "https://files.pythonhosted.org/packages/0c/5b/952928dd081bf88a83a5ccd49aaecfcd18fd0d2710c7ff07b8fb6f7032b9/mypy-1.19.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee4c11e460685c3e0c64a4c5de82ae143622410950d6be863303a1c4ba0e36d6", size = 12765799, upload-time = "2025-12-15T05:03:28.44Z" }, - { url = "https://files.pythonhosted.org/packages/2a/0d/93c2e4a287f74ef11a66fb6d49c7a9f05e47b0a4399040e6719b57f500d2/mypy-1.19.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de759aafbae8763283b2ee5869c7255391fbc4de3ff171f8f030b5ec48381b74", size = 13522149, upload-time = "2025-12-15T05:02:36.011Z" }, - { url = "https://files.pythonhosted.org/packages/7b/0e/33a294b56aaad2b338d203e3a1d8b453637ac36cb278b45005e0901cf148/mypy-1.19.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ab43590f9cd5108f41aacf9fca31841142c786827a74ab7cc8a2eacb634e09a1", size = 13810105, upload-time = "2025-12-15T05:02:40.327Z" }, - { url = "https://files.pythonhosted.org/packages/0e/fd/3e82603a0cb66b67c5e7abababce6bf1a929ddf67bf445e652684af5c5a0/mypy-1.19.1-cp310-cp310-win_amd64.whl", hash = "sha256:2899753e2f61e571b3971747e302d5f420c3fd09650e1951e99f823bc3089dac", size = 10057200, upload-time = "2025-12-15T05:02:51.012Z" }, - { url = "https://files.pythonhosted.org/packages/ef/47/6b3ebabd5474d9cdc170d1342fbf9dddc1b0ec13ec90bf9004ee6f391c31/mypy-1.19.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d8dfc6ab58ca7dda47d9237349157500468e404b17213d44fc1cb77bce532288", size = 13028539, upload-time = "2025-12-15T05:03:44.129Z" }, - { url = "https://files.pythonhosted.org/packages/5c/a6/ac7c7a88a3c9c54334f53a941b765e6ec6c4ebd65d3fe8cdcfbe0d0fd7db/mypy-1.19.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e3f276d8493c3c97930e354b2595a44a21348b320d859fb4a2b9f66da9ed27ab", size = 12083163, upload-time = "2025-12-15T05:03:37.679Z" }, - { url = "https://files.pythonhosted.org/packages/67/af/3afa9cf880aa4a2c803798ac24f1d11ef72a0c8079689fac5cfd815e2830/mypy-1.19.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2abb24cf3f17864770d18d673c85235ba52456b36a06b6afc1e07c1fdcd3d0e6", size = 12687629, upload-time = "2025-12-15T05:02:31.526Z" }, - { url = "https://files.pythonhosted.org/packages/2d/46/20f8a7114a56484ab268b0ab372461cb3a8f7deed31ea96b83a4e4cfcfca/mypy-1.19.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a009ffa5a621762d0c926a078c2d639104becab69e79538a494bcccb62cc0331", size = 13436933, upload-time = "2025-12-15T05:03:15.606Z" }, - { url = "https://files.pythonhosted.org/packages/5b/f8/33b291ea85050a21f15da910002460f1f445f8007adb29230f0adea279cb/mypy-1.19.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f7cee03c9a2e2ee26ec07479f38ea9c884e301d42c6d43a19d20fb014e3ba925", size = 13661754, upload-time = "2025-12-15T05:02:26.731Z" }, - { url = "https://files.pythonhosted.org/packages/fd/a3/47cbd4e85bec4335a9cd80cf67dbc02be21b5d4c9c23ad6b95d6c5196bac/mypy-1.19.1-cp311-cp311-win_amd64.whl", hash = "sha256:4b84a7a18f41e167f7995200a1d07a4a6810e89d29859df936f1c3923d263042", size = 10055772, upload-time = "2025-12-15T05:03:26.179Z" }, - { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, - { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, - { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, - { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, - { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, - { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, - { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, - { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, - { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, - { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, - { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, - { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, - { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, - { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, - { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, - { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, - { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, - { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, -] - -[[package]] -name = "mypy-extensions" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, -] - -[[package]] -name = "packaging" -version = "25.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, -] - -[[package]] -name = "pathspec" -version = "0.12.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, -] - -[[package]] -name = "pefile" -version = "2024.8.26" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/03/4f/2750f7f6f025a1507cd3b7218691671eecfd0bbebebe8b39aa0fe1d360b8/pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632", size = 76008, upload-time = "2024-08-26T20:58:38.155Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" }, -] - -[[package]] -name = "pluggy" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, -] - -[[package]] -name = "pydantic" -version = "2.12.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, -] - -[[package]] -name = "pydantic-core" -version = "2.41.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, - { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, - { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, - { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, - { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, - { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, - { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, - { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, - { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, - { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, - { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, - { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, - { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, - { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, - { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, - { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, - { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, - { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, - { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, - { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, - { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, - { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, - { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, - { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, - { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, - { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, - { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, - { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, - { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, - { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, - { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, - { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, - { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, - { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, - { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, - { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, - { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, - { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, - { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, - { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, - { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, - { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, - { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, - { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, - { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, - { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, - { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, - { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, - { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, - { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, - { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, - { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, - { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, - { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, - { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, - { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, - { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, - { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, - { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, - { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, - { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, - { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, - { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, - { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, - { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, - { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, - { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, - { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, - { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, - { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, - { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, - { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, - { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, - { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, - { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, - { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, - { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, - { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, - { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, - { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, - { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, - { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, - { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, - { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, - { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, - { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, - { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, - { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, - { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, - { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, - { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, - { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, - { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, - { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, - { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, - { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, - { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, - { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, - { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, - { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, -] - -[[package]] -name = "pygments" -version = "2.19.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, -] - -[[package]] -name = "pyinstaller" -version = "6.17.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "altgraph" }, - { name = "macholib", marker = "sys_platform == 'darwin'" }, - { name = "packaging" }, - { name = "pefile", marker = "sys_platform == 'win32'" }, - { name = "pyinstaller-hooks-contrib" }, - { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, - { name = "setuptools" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/01/80/9e0dad9c69a7cfd4b5aaede8c6225d762bab7247a2a6b7651e1995522001/pyinstaller-6.17.0.tar.gz", hash = "sha256:be372bd911392b88277e510940ac32a5c2a6ce4b8d00a311c78fa443f4f27313", size = 4014147, upload-time = "2025-11-24T19:43:32.109Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/35/f5/37e419d84d5284ecab11ef8b61306a3b978fe6f0fd69a9541e16bfd72e65/pyinstaller-6.17.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:4e446b8030c6e5a2f712e3f82011ecf6c7ead86008357b0d23a0ec4bcde31dac", size = 1031880, upload-time = "2025-11-24T19:42:30.862Z" }, - { url = "https://files.pythonhosted.org/packages/9e/b6/2e184879ab9cf90a1d2867fdd34d507c4d246b3cc52ca05aad00bfc70ee7/pyinstaller-6.17.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:aa9fd87aaa28239c6f0d0210114029bd03f8cac316a90bab071a5092d7c85ad7", size = 731968, upload-time = "2025-11-24T19:42:35.421Z" }, - { url = "https://files.pythonhosted.org/packages/40/76/f529de98f7e5cce7904c19b224990003fc2267eda2ee5fdd8452acb420a9/pyinstaller-6.17.0-py3-none-manylinux2014_i686.whl", hash = "sha256:060b122e43e7c0b23e759a4153be34bd70914135ab955bb18a67181e0dca85a2", size = 743217, upload-time = "2025-11-24T19:42:39.286Z" }, - { url = "https://files.pythonhosted.org/packages/a3/10/c02bfbb050cafc4c353cf69baf95407e211e1372bd286ab5ce5cbc13a30a/pyinstaller-6.17.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:cd213d1a545c97dfe4a3c40e8213ff7c5127fc115c49229f27a3fa541503444b", size = 741119, upload-time = "2025-11-24T19:42:43.12Z" }, - { url = "https://files.pythonhosted.org/packages/11/9d/69fdacfd9335695f5900a376cfe3e4aed28f0720ffc15fee81fdb9d920bc/pyinstaller-6.17.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:89c0d18ba8b62c6607abd8cf2299ae5ffa5c36d8c47f39608ce8c3f357f6099f", size = 738111, upload-time = "2025-11-24T19:42:46.97Z" }, - { url = "https://files.pythonhosted.org/packages/5e/1e/e8e36e1568f6865ac706c6e1f875c1a346ddaa9f9a8f923d66545d2240ed/pyinstaller-6.17.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:2a147b83cdebb07855bd5a663600891550062373a2ca375c58eacead33741a27", size = 737795, upload-time = "2025-11-24T19:42:50.675Z" }, - { url = "https://files.pythonhosted.org/packages/8d/15/9dc0f81ccb746c27bfa6ee53164422fe47ee079c7a717d9c4791aba78797/pyinstaller-6.17.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:f8cfbbfa6708e54fb936df6dd6eafaf133e84efb0d2fe25b91cfeefa793c4ca4", size = 736891, upload-time = "2025-11-24T19:42:54.458Z" }, - { url = "https://files.pythonhosted.org/packages/97/e6/bed54821c1ebe1275c559661d3e7bfa23c406673b515252dfbf89db56c65/pyinstaller-6.17.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:97f4c1942f7b4cd73f9e38b49cc8f5f8a6fbb44922cb60dd3073a189b77ee1ae", size = 736752, upload-time = "2025-11-24T19:42:58.144Z" }, - { url = "https://files.pythonhosted.org/packages/c7/84/897d759198676b910d69d42640b6d25d50b449f2209e18127a974cf59dbe/pyinstaller-6.17.0-py3-none-win32.whl", hash = "sha256:ce0be227a037fd4be672226db709088565484f597d6b230bceec19850fdd4c85", size = 1317851, upload-time = "2025-11-24T19:43:04.361Z" }, - { url = "https://files.pythonhosted.org/packages/2d/f5/6a122efe024433ecc34aab6f499e0bd2bbe059c639b77b0045aa2421b0bf/pyinstaller-6.17.0-py3-none-win_amd64.whl", hash = "sha256:b019940dbf7a01489d6b26f9fb97db74b504e0a757010f7ad078675befc85a82", size = 1378685, upload-time = "2025-11-24T19:43:10.395Z" }, - { url = "https://files.pythonhosted.org/packages/c4/96/14991773c9e599707a53594429ccf372f9ee638df3b7d26b65fd1a7433f0/pyinstaller-6.17.0-py3-none-win_arm64.whl", hash = "sha256:3c92a335e338170df7e615f75279cfeea97ade89e6dd7694943c8c185460f7b7", size = 1320032, upload-time = "2025-11-24T19:43:16.388Z" }, -] - -[[package]] -name = "pyinstaller-hooks-contrib" -version = "2025.10" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "packaging" }, - { name = "setuptools" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/26/4f/e33132acdb8f732978e577b8a0130a412cbfe7a3414605e3fd380a975522/pyinstaller_hooks_contrib-2025.10.tar.gz", hash = "sha256:a1a737e5c0dccf1cf6f19a25e2efd109b9fec9ddd625f97f553dac16ee884881", size = 168155, upload-time = "2025-11-22T09:34:36.138Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/86/de/a7688eed49a1d3df337cdaa4c0d64e231309a52f269850a72051975e3c4a/pyinstaller_hooks_contrib-2025.10-py3-none-any.whl", hash = "sha256:aa7a378518772846221f63a84d6306d9827299323243db890851474dfd1231a9", size = 447760, upload-time = "2025-11-22T09:34:34.753Z" }, -] - -[[package]] -name = "pytest" -version = "9.0.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, -] - -[[package]] -name = "pytest-cov" -version = "7.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "coverage", extra = ["toml"] }, - { name = "pluggy" }, - { name = "pytest" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, -] - -[[package]] -name = "pywin32-ctypes" -version = "0.2.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, -] - -[[package]] -name = "rich" -version = "14.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, -] - -[[package]] -name = "ruff" -version = "0.14.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/08/52232a877978dd8f9cf2aeddce3e611b40a63287dfca29b6b8da791f5e8d/ruff-0.14.10.tar.gz", hash = "sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4", size = 5859763, upload-time = "2025-12-18T19:28:57.98Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/60/01/933704d69f3f05ee16ef11406b78881733c186fe14b6a46b05cfcaf6d3b2/ruff-0.14.10-py3-none-linux_armv6l.whl", hash = "sha256:7a3ce585f2ade3e1f29ec1b92df13e3da262178df8c8bdf876f48fa0e8316c49", size = 13527080, upload-time = "2025-12-18T19:29:25.642Z" }, - { url = "https://files.pythonhosted.org/packages/df/58/a0349197a7dfa603ffb7f5b0470391efa79ddc327c1e29c4851e85b09cc5/ruff-0.14.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:674f9be9372907f7257c51f1d4fc902cb7cf014b9980152b802794317941f08f", size = 13797320, upload-time = "2025-12-18T19:29:02.571Z" }, - { url = "https://files.pythonhosted.org/packages/7b/82/36be59f00a6082e38c23536df4e71cdbc6af8d7c707eade97fcad5c98235/ruff-0.14.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d85713d522348837ef9df8efca33ccb8bd6fcfc86a2cde3ccb4bc9d28a18003d", size = 12918434, upload-time = "2025-12-18T19:28:51.202Z" }, - { url = "https://files.pythonhosted.org/packages/a6/00/45c62a7f7e34da92a25804f813ebe05c88aa9e0c25e5cb5a7d23dd7450e3/ruff-0.14.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6987ebe0501ae4f4308d7d24e2d0fe3d7a98430f5adfd0f1fead050a740a3a77", size = 13371961, upload-time = "2025-12-18T19:29:04.991Z" }, - { url = "https://files.pythonhosted.org/packages/40/31/a5906d60f0405f7e57045a70f2d57084a93ca7425f22e1d66904769d1628/ruff-0.14.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16a01dfb7b9e4eee556fbfd5392806b1b8550c9b4a9f6acd3dbe6812b193c70a", size = 13275629, upload-time = "2025-12-18T19:29:21.381Z" }, - { url = "https://files.pythonhosted.org/packages/3e/60/61c0087df21894cf9d928dc04bcd4fb10e8b2e8dca7b1a276ba2155b2002/ruff-0.14.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7165d31a925b7a294465fa81be8c12a0e9b60fb02bf177e79067c867e71f8b1f", size = 14029234, upload-time = "2025-12-18T19:29:00.132Z" }, - { url = "https://files.pythonhosted.org/packages/44/84/77d911bee3b92348b6e5dab5a0c898d87084ea03ac5dc708f46d88407def/ruff-0.14.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c561695675b972effb0c0a45db233f2c816ff3da8dcfbe7dfc7eed625f218935", size = 15449890, upload-time = "2025-12-18T19:28:53.573Z" }, - { url = "https://files.pythonhosted.org/packages/e9/36/480206eaefa24a7ec321582dda580443a8f0671fdbf6b1c80e9c3e93a16a/ruff-0.14.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bb98fcbbc61725968893682fd4df8966a34611239c9fd07a1f6a07e7103d08e", size = 15123172, upload-time = "2025-12-18T19:29:23.453Z" }, - { url = "https://files.pythonhosted.org/packages/5c/38/68e414156015ba80cef5473d57919d27dfb62ec804b96180bafdeaf0e090/ruff-0.14.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f24b47993a9d8cb858429e97bdf8544c78029f09b520af615c1d261bf827001d", size = 14460260, upload-time = "2025-12-18T19:29:27.808Z" }, - { url = "https://files.pythonhosted.org/packages/b3/19/9e050c0dca8aba824d67cc0db69fb459c28d8cd3f6855b1405b3f29cc91d/ruff-0.14.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59aabd2e2c4fd614d2862e7939c34a532c04f1084476d6833dddef4afab87e9f", size = 14229978, upload-time = "2025-12-18T19:29:11.32Z" }, - { url = "https://files.pythonhosted.org/packages/51/eb/e8dd1dd6e05b9e695aa9dd420f4577debdd0f87a5ff2fedda33c09e9be8c/ruff-0.14.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:213db2b2e44be8625002dbea33bb9c60c66ea2c07c084a00d55732689d697a7f", size = 14338036, upload-time = "2025-12-18T19:29:09.184Z" }, - { url = "https://files.pythonhosted.org/packages/6a/12/f3e3a505db7c19303b70af370d137795fcfec136d670d5de5391e295c134/ruff-0.14.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b914c40ab64865a17a9a5b67911d14df72346a634527240039eb3bd650e5979d", size = 13264051, upload-time = "2025-12-18T19:29:13.431Z" }, - { url = "https://files.pythonhosted.org/packages/08/64/8c3a47eaccfef8ac20e0484e68e0772013eb85802f8a9f7603ca751eb166/ruff-0.14.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1484983559f026788e3a5c07c81ef7d1e97c1c78ed03041a18f75df104c45405", size = 13283998, upload-time = "2025-12-18T19:29:06.994Z" }, - { url = "https://files.pythonhosted.org/packages/12/84/534a5506f4074e5cc0529e5cd96cfc01bb480e460c7edf5af70d2bcae55e/ruff-0.14.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c70427132db492d25f982fffc8d6c7535cc2fd2c83fc8888f05caaa248521e60", size = 13601891, upload-time = "2025-12-18T19:28:55.811Z" }, - { url = "https://files.pythonhosted.org/packages/0d/1e/14c916087d8598917dbad9b2921d340f7884824ad6e9c55de948a93b106d/ruff-0.14.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5bcf45b681e9f1ee6445d317ce1fa9d6cba9a6049542d1c3d5b5958986be8830", size = 14336660, upload-time = "2025-12-18T19:29:16.531Z" }, - { url = "https://files.pythonhosted.org/packages/f2/1c/d7b67ab43f30013b47c12b42d1acd354c195351a3f7a1d67f59e54227ede/ruff-0.14.10-py3-none-win32.whl", hash = "sha256:104c49fc7ab73f3f3a758039adea978869a918f31b73280db175b43a2d9b51d6", size = 13196187, upload-time = "2025-12-18T19:29:19.006Z" }, - { url = "https://files.pythonhosted.org/packages/fb/9c/896c862e13886fae2af961bef3e6312db9ebc6adc2b156fe95e615dee8c1/ruff-0.14.10-py3-none-win_amd64.whl", hash = "sha256:466297bd73638c6bdf06485683e812db1c00c7ac96d4ddd0294a338c62fdc154", size = 14661283, upload-time = "2025-12-18T19:29:30.16Z" }, - { url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" }, -] - -[[package]] -name = "setuptools" -version = "80.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, -] - -[[package]] -name = "shellgame" -version = "0.1.0" -source = { editable = "." } -dependencies = [ - { name = "click" }, - { name = "pydantic" }, - { name = "rich" }, - { name = "typing-extensions" }, -] - -[package.optional-dependencies] -build = [ - { name = "pyinstaller" }, -] -dev = [ - { name = "mypy" }, - { name = "pytest" }, - { name = "pytest-cov" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "click", specifier = ">=8.1.0" }, - { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.0.0" }, - { name = "pydantic", specifier = ">=2.0.0" }, - { name = "pyinstaller", marker = "extra == 'build'", specifier = ">=5.0.0" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, - { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0.0" }, - { name = "rich", specifier = ">=13.0.0" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, - { name = "typing-extensions", specifier = ">=4.8.0" }, -] -provides-extras = ["dev", "build"] - -[[package]] -name = "tomli" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" }, - { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" }, - { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" }, - { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" }, - { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" }, - { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" }, - { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" }, - { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" }, - { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" }, - { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" }, - { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" }, - { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" }, - { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" }, - { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" }, - { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" }, - { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" }, - { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" }, - { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" }, - { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" }, - { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" }, - { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" }, - { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" }, - { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" }, - { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" }, - { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" }, - { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" }, - { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" }, - { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" }, - { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" }, - { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" }, - { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" }, - { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" }, - { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" }, - { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" }, - { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" }, - { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" }, - { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" }, - { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" }, - { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" }, - { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" }, - { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, -] - -[[package]] -name = "typing-extensions" -version = "4.15.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, -] - -[[package]] -name = "typing-inspection" -version = "0.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, -]