diff --git a/.gitignore b/.gitignore index d25b81d..68a3872 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ *.test *.out .DS_Store +/.claude/worktrees/ diff --git a/README.md b/README.md index 1019354..fe86061 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ OpsQuest validates the result, not a prescribed command. Equivalent supported so ## What is included - 23 Linux missions across four ordered learning worlds -- 16 optional, disposable Docker missions across three worlds: Foundations, Container Triage, and Network Plumbing +- 16 optional, disposable Docker missions across three worlds: It Works on My Machine, Container Triage, and Network Plumbing - An isolated in-memory filesystem, environment, process table, and archives - Quote-aware globs and variables, pipelines, redirection, and command history - A compact virtual `vi` and bounded virtual shell scripts @@ -101,8 +101,6 @@ Docker input is parsed into a small set of typed teaching actions. OpsQuest cons The optional web companion binds only to an ephemeral `127.0.0.1` port, uses a one-time pairing URL, and exposes a read-only mission projection. It cannot submit commands, mutate the profile or attempt, address Docker resources, or approve completion. -The optional web companion binds only to an ephemeral `127.0.0.1` port, uses a one-time pairing URL, and exposes a read-only mission projection. It cannot submit commands, mutate the profile or attempt, address Docker resources, or approve completion. - Read [Sandbox and safety](docs/technical/sandbox-and-safety.md) for trust boundaries, quotas, cleanup behavior, and threat controls. ## Documentation diff --git a/docs/play/controls-and-commands.md b/docs/play/controls-and-commands.md index 418cc37..e1bc48b 100644 --- a/docs/play/controls-and-commands.md +++ b/docs/play/controls-and-commands.md @@ -36,6 +36,8 @@ rmdir sed sh sort stat tail tar touch tr uniq vi wc whoami The shell also supports quote-aware variables and globs, pipelines (`|`), input redirection (`<`), and output redirection (`>` and `>>`). It intentionally implements a teaching subset of each command. Use `help COMMAND` for the exact supported flags and examples. +Enter one command per line. Command lists (`;`, `&&`, `||`), background jobs (`&`), subshells, command substitution, special parameters such as `$?`, and file-descriptor redirection such as `2>` are rejected with an explanation before anything runs. Quote or escape these characters to use them as data. + ## Mission navigation ```console diff --git a/docs/play/mission-map.md b/docs/play/mission-map.md index 09a5ad0..c6c8df8 100644 --- a/docs/play/mission-map.md +++ b/docs/play/mission-map.md @@ -17,7 +17,7 @@ Editable source: [`learning-journey.excalidraw`](diagrams/learning-journey.excal The curriculum has two independent tracks: - **Linux:** 23 missions across four ordered worlds. Bare `opsquest play` follows this track. -- **Docker:** 16 optional missions across three worlds: Foundations, Container Triage, then Network Plumbing. Docker readiness never blocks Linux play. +- **Docker:** 16 optional missions across three worlds: It Works on My Machine, Container Triage, then Network Plumbing. Docker readiness never blocks Linux play. Display numbers are global across both tracks: Docker World 1 occupies Missions 20–25, Linux World 4 resumes at Mission 26, Docker World 2 continues at Mission 30, and Docker World 3 at Mission 35. World and stage positions are derived separately inside each track. Persisted completions use stable mission IDs rather than display numbers, so profiles created before this expansion remain compatible. @@ -106,7 +106,7 @@ The final Linux world moves from executing a supplied script to repairing reusab ### Docker World 1: It Works on My Machine -Docker Foundations progresses through listing, logs, sanitized exit status, targeted stop, multi-service recovery, and a mixed lifecycle handoff. Logical aliases hide real container names and IDs, keeping every lesson focused on observable state for exact attempt-owned resources. +The first Docker world progresses through listing, logs, sanitized exit status, targeted stop, multi-service recovery, and a mixed lifecycle handoff. Logical aliases hide real container names and IDs, keeping every lesson focused on observable state for exact attempt-owned resources. ### Docker World 2: Container Triage diff --git a/docs/play/missions-and-progress.md b/docs/play/missions-and-progress.md index b507e6d..9276068 100644 --- a/docs/play/missions-and-progress.md +++ b/docs/play/missions-and-progress.md @@ -33,8 +33,11 @@ Inside a mission: | `quit` | Leave the current attempt | The command guide, objective reminder, and `help COMMAND` are free. Progressive -hints trade some XP for increasingly specific guidance. Hint progress remains -recorded while a mission is incomplete, so quitting cannot erase the cost. +hints trade some XP for increasingly specific guidance: each one lowers the +mission's reward, which never falls below a quarter of its base XP. Hint +progress remains recorded while a mission is incomplete, so quitting cannot +erase the cost. Hints you have already revealed are repeated when you resume the +mission and when `hint` runs out, at no extra cost. ## Choose a route @@ -56,9 +59,12 @@ starts fresh disposable state while retaining saved profile progress. ## Completion and rewards When every observable condition passes, OpsQuest closes the active environment -before recording completion. The first completion awards hint-adjusted XP, -records practiced commands, and may unlock achievements. Replays retain the -original completion and reward. +before recording completion. The first completion awards hint-adjusted XP and +may unlock achievements. Replays retain the original completion and reward. + +Every successful teaching command counts toward command mastery as soon as it +runs, including during replays. Lab utilities such as `help`, `man`, `clear`, +and `history` are free to use and do not count. The displayed level is derived from total XP. Ranks progress through: @@ -84,5 +90,10 @@ Completions use stable mission IDs rather than display positions. The mission map can therefore grow without invalidating existing completions, provided those IDs remain compatible. +`opsquest profile --name NAME` changes your display name, and `opsquest doctor` +shows where the profile is saved. `opsquest reset` asks before erasing all +progress; add `--yes` to skip the question. If the profile file is damaged, +OpsQuest names the file so you can move it aside or reset. + See [Profiles and compatibility](../technical/profiles-and-compatibility.md) for storage and migration behavior. diff --git a/docs/technical/sandbox-and-safety.md b/docs/technical/sandbox-and-safety.md index b42fe1c..ed4cc3b 100644 --- a/docs/technical/sandbox-and-safety.md +++ b/docs/technical/sandbox-and-safety.md @@ -58,7 +58,7 @@ Editable source: [`command-execution-pipeline.mmd`](diagrams/command-execution-p [`Sandbox.Execute`](https://github.com/Aleksandergreg/go-cli-tool/blob/main/internal/sandbox/shell.go) processes a line in explicit stages: 1. **Bound input:** reject a command line over 64 KiB before adding it to the attempt's 100-entry history. -2. **Lex:** recognize words, quotes, escapes, comments, variables, pipes, and `<`, `>`, or `>>`. Expansion reads only the sandbox environment. +2. **Lex:** reject unquoted shell syntax outside the teaching subset (command lists, background jobs, subshells, substitutions, special parameters, and file-descriptor redirection), then recognize words, quotes, escapes, comments, variables, pipes, and `<`, `>`, or `>>`. Interactive and script lines share these rules. Expansion reads only the sandbox environment. 3. **Parse:** build pipeline stages and attach at most one input and output redirection to each stage. 4. **Expand:** resolve eligible globs against the virtual filesystem and enforce expanded token and argument budgets. 5. **Preflight compositions:** reject unsupported interactive-editor or script placement before an earlier pipeline stage can mutate state. @@ -77,7 +77,7 @@ One `sandbox.Sandbox` owns: | State | Representation | Persistence | | --- | --- | --- | -| Files and directories | Normalized absolute paths to typed entries with content, mode, and owner | Attempt only | +| Files and directories | Normalized absolute paths to typed entries with content, mode, and owner; the virtual `HOME` directory always exists | Attempt only | | Working directory | Virtual absolute path | Attempt only; child scripts restore caller scope | | Environment | String map initialized with virtual `HOME` and `USER` | Attempt only; exported child-script values restore on return | | Processes | Mission-provided PID map and running flags | Attempt only; no host PID is visible or signalable | diff --git a/internal/cli/app.go b/internal/cli/app.go index f119e87..deaad53 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -128,8 +128,8 @@ func (a *App) Run(args []string) error { case "reset": return a.runReset(args[1:]) case "version", "--version", "-v": - if len(args) > 1 { - return fmt.Errorf("version does not accept arguments") + if help, err := a.parseNoArgs("version", args[1:]); help || err != nil { + return err } fmt.Fprintf(a.out, "%s %s\n", a.style.Header("OpsQuest"), a.style.Accent(buildinfo.Version)) return nil diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go index f5698d2..b38058d 100644 --- a/internal/cli/app_test.go +++ b/internal/cli/app_test.go @@ -92,4 +92,77 @@ func TestSubcommandHelpIsSuccessful(t *testing.T) { if !strings.Contains(errOut.String(), "Usage: opsquest play") { t.Fatalf("help output = %s", errOut.String()) } + + for _, args := range [][]string{{"guide", "--help"}, {"guide", "-h"}, {"version", "--help"}, {"--version", "-h"}, {"show", "3", "--help"}} { + t.Run(strings.Join(args, " "), func(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + app, out, errOut := testApp(t, "", store) + if err := app.Run(args); err != nil { + t.Fatalf("%v returned error: %v", args, err) + } + if !strings.Contains(errOut.String(), "Usage: opsquest ") || out.Len() != 0 { + t.Fatalf("%v stdout = %q, stderr = %q; want usage only", args, out.String(), errOut.String()) + } + if player, err := store.Load(); err != nil || player.Onboarded { + t.Fatalf("%v changed the profile: %#v, %v", args, player, err) + } + }) + } +} + +func TestNoArgumentCommandsRejectArguments(t *testing.T) { + for _, args := range [][]string{{"guide", "extra"}, {"version", "extra"}, {"guide", "--bogus"}} { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + app, _, _ := testApp(t, "", store) + if err := app.Run(args); err == nil { + t.Errorf("%v was accepted", args) + } + } +} + +func TestFlagErrorsAreReportedOnce(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + app, _, errOut := testApp(t, "", store) + err := app.Run([]string{"show", "--bogus"}) + if err == nil || !strings.Contains(err.Error(), "flag provided but not defined: -bogus") { + t.Fatalf("show --bogus error = %v", err) + } + if strings.Contains(errOut.String(), "bogus") || strings.Count(errOut.String(), "Usage: opsquest show") != 1 { + t.Fatalf("stderr = %q; want usage once and the error left to the caller", errOut.String()) + } +} + +func TestFlagsMayFollowTheMissionArgument(t *testing.T) { + tests := []struct { + args []string + wantMission string + wantOnce bool + wantErr string + }{ + {args: []string{"4", "--once"}, wantMission: "4", wantOnce: true}, + {args: []string{"--once", "4"}, wantMission: "4", wantOnce: true}, + {args: []string{"linux-workspace", "-web", "--once"}, wantMission: "linux-workspace", wantOnce: true}, + {args: []string{"--", "--once"}, wantMission: "--once"}, + {args: []string{"4", "--track", "docker"}, wantErr: "MISSION cannot be combined"}, + {args: []string{"4", "5"}, wantErr: "usage: opsquest play"}, + {args: []string{"4", "--bogus"}, wantErr: "flag provided but not defined"}, + } + for _, test := range tests { + t.Run(strings.Join(test.args, " "), func(t *testing.T) { + app, _, _ := testApp(t, "", profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex")) + options, help, err := app.parsePlayOptions(test.args) + if test.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("parsePlayOptions(%q) error = %v, want %q", test.args, err, test.wantErr) + } + return + } + if err != nil || help { + t.Fatalf("parsePlayOptions(%q) = help %v, error %v", test.args, help, err) + } + if options.mission != test.wantMission || options.once != test.wantOnce { + t.Fatalf("parsePlayOptions(%q) = %#v", test.args, options) + } + }) + } } diff --git a/internal/cli/docker_test.go b/internal/cli/docker_test.go index b0e4109..d98f449 100644 --- a/internal/cli/docker_test.go +++ b/internal/cli/docker_test.go @@ -178,3 +178,40 @@ func TestDockerMissionToolHintsAndOutcomeCompletionPersist(t *testing.T) { t.Fatal("completed Docker mission retained in-progress hints") } } + +func TestDockerFollowUpCommandsStayOnTheDockerTrack(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + app, out, errOut := testApp(t, "docker start api\n", store) + app.factory = &cliDockerFactory{available: true, detail: "test Docker engine ready"} + if err := app.Run([]string{"play", "--once", "20"}); err != nil { + t.Fatalf("play Docker mission error = %v; stderr = %s", err, errOut.String()) + } + if want := "Continue with opsquest play --track docker, or jump anywhere with opsquest map --track docker."; !strings.Contains(out.String(), want) { + t.Fatalf("Docker recommendation lacks %q:\n%s", want, out.String()) + } + + tests := []struct { + name string + ref string + route playRoute + want string + }{ + {name: "Docker world", ref: "20", route: playRoute{kind: playRouteWorld, worldNumber: 1}, want: "Resume with 'opsquest play --track docker --world 1'."}, + {name: "Docker sequence", ref: "20", route: playRoute{kind: playRouteSequential}, want: "Resume unfinished missions with 'opsquest play --track docker'."}, + {name: "Linux world", ref: "1", route: playRoute{kind: playRouteWorld, worldNumber: 1}, want: "Resume with 'opsquest play --world 1'."}, + {name: "Linux sequence", ref: "1", route: playRoute{kind: playRouteSequential}, want: "Resume unfinished missions with 'opsquest play'."}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + app, out, _ := testApp(t, "", profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex")) + item, found := app.catalog.Find(test.ref) + if !found { + t.Fatalf("mission %s not found", test.ref) + } + app.printRouteFinished(test.route, item, profile.New("alex")) + if !strings.Contains(out.String(), test.want) { + t.Fatalf("route end = %q, want %q", out.String(), test.want) + } + }) + } +} diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index d86921c..c46799f 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -1,6 +1,7 @@ package cli import ( + "errors" "fmt" "github.com/aleksandergregersen/opsquest/internal/game" @@ -16,14 +17,17 @@ func (a *App) runDoctor(args []string) error { if flags.NArg() > 0 { return fmt.Errorf("doctor does not accept positional arguments") } - player, err := a.loadPlayer() - if err != nil { - return fmt.Errorf("profile check failed: %w", err) - } fmt.Fprintln(a.out, a.style.Header("OpsQuest diagnostics")) check := a.style.Success("✓") fmt.Fprintf(a.out, " %s embedded catalog: %d missions (%d Linux, %d Docker)\n", check, len(a.catalog.All()), len(a.catalog.InTrack(mission.TrackLinux)), len(a.catalog.InTrack(mission.TrackDocker))) - fmt.Fprintf(a.out, " %s profile: version %d, %d completed missions\n", check, player.Version, len(player.Completed)) + // A broken profile is reported like any other failed check so the + // remaining diagnostics still run. + player, profileErr := a.loadPlayer() + if profileErr != nil { + fmt.Fprintf(a.out, " %s profile: %v\n", a.style.Failure("✗"), profileErr) + } else { + fmt.Fprintf(a.out, " %s profile: version %d, %d completed missions\n", check, player.Version, len(player.Completed)) + } fmt.Fprintf(a.out, " %s profile path: %s\n", check, a.store.Path()) fmt.Fprintf(a.out, " %s Linux labs: in-memory; no host shell or filesystem access\n", check) dockerReady := false @@ -36,7 +40,11 @@ func (a *App) runDoctor(args []string) error { fmt.Fprintf(a.out, " %s docker labs: unavailable · %s\n", a.style.Warning("!"), availability.Detail) } } - return a.reportOrphanedLabs(*cleanup, dockerReady) + cleanupErr := a.reportOrphanedLabs(*cleanup, dockerReady) + if profileErr != nil { + return errors.Join(cleanupErr, fmt.Errorf("profile check failed; see the diagnostics above")) + } + return cleanupErr } // reportOrphanedLabs checks for, or with cleanup removes, lab resources left diff --git a/internal/cli/doctor_test.go b/internal/cli/doctor_test.go index af0e51f..6a337ae 100644 --- a/internal/cli/doctor_test.go +++ b/internal/cli/doctor_test.go @@ -3,6 +3,7 @@ package cli import ( "bytes" "fmt" + "os" "path/filepath" "strings" "testing" @@ -98,3 +99,39 @@ func TestDoctorReportsAndCleansOrphanedDockerLabs(t *testing.T) { } }) } + +func TestDoctorReportsACorruptProfileAndKeepsChecking(t *testing.T) { + path := filepath.Join(t.TempDir(), "profile.json") + if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil { + t.Fatal(err) + } + catalog, err := mission.LoadCatalog() + if err != nil { + t.Fatal(err) + } + out := &bytes.Buffer{} + factory := &cliJanitorFactory{cliDockerFactory: cliDockerFactory{available: true, detail: "Docker is ready for this mission."}} + app := New(Config{Out: out, ErrOut: &bytes.Buffer{}, Catalog: catalog, Store: profile.NewStore(path, "alex"), Factory: factory}) + + err = app.Run([]string{"doctor"}) + if err == nil || !strings.Contains(err.Error(), "profile check failed") { + t.Fatalf("doctor error = %v, want a failed profile check", err) + } + output := out.String() + for _, want := range []string{ + "embedded catalog:", + "✗ profile: decode profile " + path, + "opsquest reset", + "profile path: " + path, + "Linux labs: in-memory", + "docker labs: ready", + "docker cleanup: no orphaned lab resources", + } { + if !strings.Contains(output, want) { + t.Errorf("doctor output lacks %q:\n%s", want, output) + } + } + if content, readErr := os.ReadFile(path); readErr != nil || string(content) != "{not json" { + t.Fatalf("doctor changed the corrupt profile: %q, %v", content, readErr) + } +} diff --git a/internal/cli/flags.go b/internal/cli/flags.go index 3c4b733..d0510dd 100644 --- a/internal/cli/flags.go +++ b/internal/cli/flags.go @@ -3,12 +3,16 @@ package cli import ( "errors" "flag" + "fmt" + "io" "strings" ) func (a *App) newFlagSet(name string, usage func()) *flag.FlagSet { flags := flag.NewFlagSet(name, flag.ContinueOnError) - flags.SetOutput(a.errOut) + // Parse errors are returned and reported once by the caller; the flag + // package would otherwise print the same message first. + flags.SetOutput(io.Discard) flags.Usage = usage return flags } @@ -21,15 +25,57 @@ func parseFlags(flags *flag.FlagSet, args []string) (bool, error) { return false, err } +// parseInterspersedFlags parses flags before or after positional arguments, +// so `opsquest play 4 --once` works like `opsquest play --once 4`. A "--" +// terminator still makes every later argument positional. +func parseInterspersedFlags(flags *flag.FlagSet, args []string) ([]string, bool, error) { + var positionals []string + for { + if help, err := parseFlags(flags, args); help || err != nil { + return nil, help, err + } + remaining := flags.Args() + if len(remaining) == 0 { + return positionals, false, nil + } + if consumed := len(args) - len(remaining); consumed > 0 && args[consumed-1] == "--" { + return append(positionals, remaining...), false, nil + } + positionals = append(positionals, remaining[0]) + args = remaining[1:] + } +} + +// parseNoArgs handles commands that accept only -h/--help. +func (a *App) parseNoArgs(name string, args []string) (bool, error) { + flags := a.newFlagSet(name, func() { fmt.Fprintf(a.errOut, "Usage: opsquest %s\n", name) }) + if help, err := parseFlags(flags, args); help || err != nil { + return help, err + } + if flags.NArg() > 0 { + return false, fmt.Errorf("%s does not accept arguments", name) + } + return false, nil +} + func flagProvided(flags *flag.FlagSet, name string) bool { provided := false flags.Visit(func(item *flag.Flag) { provided = provided || item.Name == name }) return provided } +// hasFlag reports whether args set the named flag in any form the flag +// package accepts: -name, --name, -name=value, or --name=value. func hasFlag(args []string, name string) bool { for _, arg := range args { - if arg == name || strings.HasPrefix(arg, name+"=") { + if arg == "--" { + return false + } + trimmed := strings.TrimPrefix(strings.TrimPrefix(arg, "-"), "-") + if trimmed == arg { + continue + } + if trimmed == name || strings.HasPrefix(trimmed, name+"=") { return true } } diff --git a/internal/cli/guide.go b/internal/cli/guide.go index 98c5f9c..044c627 100644 --- a/internal/cli/guide.go +++ b/internal/cli/guide.go @@ -7,8 +7,8 @@ import ( ) func (a *App) runGuide(args []string) error { - if len(args) != 0 { - return fmt.Errorf("guide does not accept arguments") + if help, err := a.parseNoArgs("guide", args); help || err != nil { + return err } player, err := a.loadPlayer() if err != nil { @@ -31,7 +31,7 @@ func (a *App) printQuickStart() { fmt.Fprintln(a.out, a.style.Section("THE THREE RULES")) fmt.Fprintf(a.out, " %s Produce the objective's result; the final outcome is what counts.\n", a.style.Accent("1.")) fmt.Fprintf(a.out, " %s Try any supported route. Suggested tools orient you without requiring one command.\n", a.style.Accent("2.")) - fmt.Fprintf(a.out, " %s Hints are always available, but they reduce bonus XP.\n", a.style.Accent("3.")) + fmt.Fprintf(a.out, " %s Hints are always available, but each one lowers the mission's XP reward.\n", a.style.Accent("3.")) fmt.Fprintln(a.out) fmt.Fprintln(a.out, a.style.Section("PROGRESSION")) fmt.Fprintf(a.out, " %s follows the next incomplete stage; %s shows all four Linux worlds.\n", @@ -56,7 +56,7 @@ func (a *App) printGuide() { fmt.Fprintf(a.out, " %s Explore the lab and produce the requested result.\n", a.style.Accent("1.")) fmt.Fprintf(a.out, " %s Use any supported command sequence—the final outcome is what counts.\n", a.style.Accent("2.")) fmt.Fprintf(a.out, " %s Earn XP, discover commands, and continue to the next incomplete stage.\n", a.style.Accent("3.")) - fmt.Fprintln(a.out, " Hints are always available; each one reduces the mission's bonus XP, never your progress.") + fmt.Fprintln(a.out, " Hints are always available; each one lowers the mission's XP reward (never below a quarter), never your progress.") fmt.Fprintln(a.out) fmt.Fprintln(a.out, a.style.Section("YOUR LEARNING PATH")) diff --git a/internal/cli/navigation_test.go b/internal/cli/navigation_test.go index 7e5f603..da20e68 100644 --- a/internal/cli/navigation_test.go +++ b/internal/cli/navigation_test.go @@ -202,6 +202,44 @@ func TestMissionPromptParsesQuotedNavigationArguments(t *testing.T) { } } +func TestMissionPromptCampaignFilterSearchesEveryTrack(t *testing.T) { + tests := []struct { + name string + line string + want string + exclude string + }{ + {name: "other track", line: `list --campaign "Container Triage"`, want: "0/5 missions complete", exclude: "No missions match"}, + {name: "explicit track still wins", line: `list -track=linux --campaign "Container Triage"`, want: "No missions match", exclude: "Janitor Duty"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + seedCompletedMissions(t, store) + app, out, errOut := testApp(t, test.line+"\nquit\n", store) + if err := app.Run([]string{"play", "3"}); err != nil { + t.Fatalf("Run() error = %v; stderr = %s", err, errOut.String()) + } + if !strings.Contains(out.String(), test.want) || strings.Contains(out.String(), test.exclude) { + t.Fatalf("in-lab %q output lacks %q or contains %q:\n%s", test.line, test.want, test.exclude, out.String()) + } + }) + } +} + +func TestHasFlagRecognizesFlagPackageForms(t *testing.T) { + for _, args := range [][]string{{"--track", "docker"}, {"-track", "docker"}, {"--track=docker"}, {"-track=docker"}} { + if !hasFlag(args, "track") { + t.Errorf("hasFlag(%q, track) = false", args) + } + } + for _, args := range [][]string{{"--tracks"}, {"track"}, {"--", "--track"}} { + if hasFlag(args, "track") { + t.Errorf("hasFlag(%q, track) = true", args) + } + } +} + func TestMissionPromptReportsMalformedNavigation(t *testing.T) { store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") app, out, errOut := testApp(t, "list --campaign \"First Day\nquit\n", store) diff --git a/internal/cli/play.go b/internal/cli/play.go index ae08d61..688af07 100644 --- a/internal/cli/play.go +++ b/internal/cli/play.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strconv" "strings" "time" @@ -107,10 +108,11 @@ func (a *App) parsePlayOptions(args []string) (playOptions, bool, error) { worldNumber := flags.Int("world", 0, "start the next incomplete stage in a world") once := flags.Bool("once", false, "return after one completed mission") web := flags.Bool("web", false, "show mission guidance in a local browser companion") - if help, err := parseFlags(flags, args); help || err != nil { + positionals, help, err := parseInterspersedFlags(flags, args) + if help || err != nil { return playOptions{}, help, err } - if flags.NArg() > 1 { + if len(positionals) > 1 { return playOptions{}, false, fmt.Errorf("usage: opsquest play [--track linux|docker] [--world NUMBER] [--once] [--web] [MISSION]") } trackProvided, worldProvided := flagProvided(flags, "track"), flagProvided(flags, "world") @@ -119,7 +121,9 @@ func (a *App) parsePlayOptions(args []string) (playOptions, bool, error) { worldNumber: *worldNumber, once: *once, web: *web, - mission: flags.Arg(0), + } + if len(positionals) == 1 { + options.mission = positionals[0] } if options.track != mission.TrackLinux && options.track != mission.TrackDocker { return playOptions{}, false, fmt.Errorf("unknown track %q; use linux or docker", *track) @@ -127,7 +131,7 @@ func (a *App) parsePlayOptions(args []string) (playOptions, bool, error) { if worldProvided && options.worldNumber < 1 { return playOptions{}, false, fmt.Errorf("world number must be positive") } - if flags.NArg() == 1 && (worldProvided || trackProvided) { + if len(positionals) == 1 && (worldProvided || trackProvided) { return playOptions{}, false, fmt.Errorf("MISSION cannot be combined with --track or --world") } return options, false, nil @@ -156,7 +160,7 @@ func (a *App) startingMission(options playOptions, player profile.Profile) (miss default: item, found := a.catalog.NextInTrack(options.track, player.IsComplete) if !found { - fmt.Fprintf(a.out, "%s\n", a.style.Success(fmt.Sprintf("%s track complete! Replay a mission or inspect other worlds with 'opsquest map'.", trackDisplayName(options.track)))) + fmt.Fprintf(a.out, "%s\n", a.style.Success(fmt.Sprintf("%s track complete! Replay a mission or inspect other worlds with '%s'.", trackDisplayName(options.track), trackCommand("map", options.track)))) return mission.Mission{}, playRoute{}, false, nil } return item, playRoute{kind: playRouteRecommended}, true, nil @@ -207,7 +211,9 @@ func (a *App) newSession(item mission.Mission, player *profile.Profile, reader g Reader: reader, Catalog: a.catalog, ListMissions: func(args []string) error { - if !hasFlag(args, "--track") { + // Default to the current track, but leave a campaign filter free + // to find worlds on any track, as the top-level list does. + if !hasFlag(args, "track") && !hasFlag(args, "campaign") { args = append([]string{"--track", currentTrack}, args...) } return a.listMissions(args, *player, true) @@ -268,12 +274,13 @@ func (a *App) printRouteFinished(route playRoute, current mission.Mission, playe fmt.Fprintf(a.out, "\n%s\n", a.style.Success(fmt.Sprintf("World %d complete: %s!", placement.WorldNumber, placement.WorldName))) return } - fmt.Fprintf(a.out, "\n%s\n", a.style.Accent(fmt.Sprintf("Reached the end of World %d; unfinished stages remain. Resume with 'opsquest play --world %d'.", route.worldNumber, route.worldNumber))) + resume := trackCommand("play", current.EffectiveTrack(), "--world", strconv.Itoa(route.worldNumber)) + fmt.Fprintf(a.out, "\n%s\n", a.style.Accent(fmt.Sprintf("Reached the end of World %d; unfinished stages remain. Resume with '%s'.", route.worldNumber, resume))) return } if _, remaining := a.catalog.NextInTrack(current.EffectiveTrack(), player.IsComplete); remaining { - fmt.Fprintf(a.out, "\n%s\n", a.style.Accent("Reached the end of the selected route. Resume unfinished missions with 'opsquest play'.")) + fmt.Fprintf(a.out, "\n%s\n", a.style.Accent(fmt.Sprintf("Reached the end of the selected route. Resume unfinished missions with '%s'.", trackCommand("play", current.EffectiveTrack())))) return } fmt.Fprintf(a.out, "\n%s\n", a.style.Success(fmt.Sprintf("%s track complete!", trackDisplayName(current.EffectiveTrack())))) @@ -293,5 +300,16 @@ func (a *App) printNextRecommendation(completed mission.Mission, player profile. placement, _ := a.catalog.Placement(next.ID) fmt.Fprintf(a.out, "\n%s\n", a.style.Section("NEXT RECOMMENDED")) fmt.Fprintf(a.out, "Mission %02d: %s · World %d, Stage %d/%d\n", next.Number, next.Title, placement.WorldNumber, placement.StageNumber, placement.StageTotal) - fmt.Fprintf(a.out, "Continue with %s, or jump anywhere with %s.\n", a.style.Accent("opsquest play"), a.style.Accent("opsquest map")) + track := completed.EffectiveTrack() + fmt.Fprintf(a.out, "Continue with %s, or jump anywhere with %s.\n", a.style.Accent(trackCommand("play", track)), a.style.Accent(trackCommand("map", track))) +} + +// trackCommand is the opsquest subcommand that stays on track. Linux is the +// default track, so only other tracks need an explicit --track flag. +func trackCommand(subcommand, track string, flags ...string) string { + parts := []string{"opsquest", subcommand} + if track != mission.TrackLinux { + parts = append(parts, "--track", track) + } + return strings.Join(append(parts, flags...), " ") } diff --git a/internal/cli/play_test.go b/internal/cli/play_test.go index e85d382..95ca56b 100644 --- a/internal/cli/play_test.go +++ b/internal/cli/play_test.go @@ -2,10 +2,12 @@ package cli import ( "path/filepath" + "strconv" "strings" "testing" "time" + "github.com/aleksandergregersen/opsquest/internal/mission" "github.com/aleksandergregersen/opsquest/internal/profile" ) @@ -276,6 +278,58 @@ func TestHintPenaltySurvivesAPausedAttempt(t *testing.T) { } } +func TestRevealedHintsCanBeRereadWithoutExtraCost(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + catalog, err := mission.LoadCatalog() + if err != nil { + t.Fatal(err) + } + item, _ := catalog.Find("linux-read-handoff") + if len(item.Hints) < 2 { + t.Fatalf("mission hints = %d, want at least 2 for this test", len(item.Hints)) + } + + paused, pausedOut, _ := testApp(t, "hint\nquit\n", store) + if err := paused.Run([]string{"play", "--once", item.ID}); err != nil { + t.Fatal(err) + } + if strings.Contains(pausedOut.String(), "REVEALED HINTS") { + t.Fatalf("fresh attempt listed revealed hints before any were used:\n%s", pausedOut.String()) + } + + exhaust := strings.Repeat("hint\n", len(item.Hints)) + "hint\nquit\n" + resumed, out, errOut := testApp(t, exhaust, store) + if err := resumed.Run([]string{"play", "--once", item.ID}); err != nil { + t.Fatalf("resumed play error = %v; stderr = %s", err, errOut.String()) + } + banner, afterBanner, found := strings.Cut(out.String(), "Controls:") + if !found { + t.Fatalf("resumed output lacks mission controls:\n%s", out.String()) + } + if !strings.Contains(banner, "REVEALED HINTS (no extra cost)") || !strings.Contains(banner, "Hint 1/"+strconv.Itoa(len(item.Hints))+": "+item.Hints[0]) { + t.Fatalf("resumed banner did not repeat the paid hint:\n%s", banner) + } + if strings.Contains(banner, item.Hints[1]) { + t.Fatalf("resumed banner revealed an unpaid hint:\n%s", banner) + } + _, exhausted, found := strings.Cut(afterBanner, "No more hints.") + if !found { + t.Fatalf("exhausted hint request was not reported:\n%s", afterBanner) + } + for _, hint := range item.Hints { + if !strings.Contains(exhausted, hint) { + t.Errorf("exhausted hint request did not repeat %q:\n%s", hint, exhausted) + } + } + player, err := store.Load() + if err != nil { + t.Fatal(err) + } + if got := player.MissionHints(item.ID); got != len(item.Hints) { + t.Fatalf("saved hints = %d, want %d; rereading must not record extra hints", got, len(item.Hints)) + } +} + func TestHintReportsTheActualFloorAdjustedCost(t *testing.T) { store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") app, out, errOut := testApp(t, "hint\nhint\nhint\nhint\nhint\nquit\n", store) diff --git a/internal/cli/profile.go b/internal/cli/profile.go index 0b04517..ac9a88f 100644 --- a/internal/cli/profile.go +++ b/internal/cli/profile.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "github.com/aleksandergregersen/opsquest/internal/game" "github.com/aleksandergregersen/opsquest/internal/mission" "github.com/aleksandergregersen/opsquest/internal/profile" ) @@ -79,7 +80,7 @@ func (a *App) runProfile(args []string) error { fmt.Fprintf(a.out, "%s %s %3d%% %s\n", a.style.Section("Docker"), styledProgressBar(a.style, dockerCompleted, dockerTotal, 20), percentage(dockerCompleted, dockerTotal), dockerState) printWorlds(mission.TrackDocker) fmt.Fprintf(a.out, "%s\n\n", a.style.Muted(fmt.Sprintf("K8s %s locked", progressBar(0, 20, 20)))) - fmt.Fprintf(a.out, "Commands mastered: %d\n", len(player.Commands)) + fmt.Fprintf(a.out, "Commands mastered: %d\n", len(game.PracticedCommands(player))) fmt.Fprintf(a.out, "Missions completed: %d\n", completed) completedHints, activeHints := player.HintsUsed(), player.ActiveHints() fmt.Fprintf(a.out, "Hints used: %d (completed: %d · active: %d)\n", completedHints+activeHints, completedHints, activeHints) @@ -99,7 +100,7 @@ func (a *App) runCommands(args []string) error { if err != nil { return err } - commands := player.MasteredCommands() + commands := game.PracticedCommands(player) if len(commands) == 0 { fmt.Fprintln(a.out, "No commands mastered yet. Start with 'opsquest play'.") return nil diff --git a/internal/cli/profile_test.go b/internal/cli/profile_test.go index a1d3ddb..1d9e4e4 100644 --- a/internal/cli/profile_test.go +++ b/internal/cli/profile_test.go @@ -39,6 +39,40 @@ func TestProfileRenameShowAndDoctor(t *testing.T) { } } +func TestCommandMasteryHidesLegacyMetaCommands(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + player := profile.New("alex") + player.RecordCommands([]string{"help", "man", "clear", "history", "grep", "ls"}) + if err := store.Save(player); err != nil { + t.Fatal(err) + } + + app, out, errOut := testApp(t, "", store) + if err := app.Run([]string{"commands"}); err != nil { + t.Fatalf("commands error = %v; stderr = %s", err, errOut.String()) + } + if !strings.Contains(out.String(), "COMMAND MASTERY (2)") { + t.Fatalf("commands output did not count only teaching commands:\n%s", out.String()) + } + for _, meta := range []string{"help", "man", "clear", "history"} { + if strings.Contains(out.String(), meta) { + t.Errorf("commands output listed meta command %q:\n%s", meta, out.String()) + } + } + + profileApp, profileOut, _ := testApp(t, "", store) + if err := profileApp.Run([]string{"profile"}); err != nil { + t.Fatal(err) + } + if !strings.Contains(profileOut.String(), "Commands mastered: 2\n") { + t.Fatalf("profile counted meta commands:\n%s", profileOut.String()) + } + saved, err := store.Load() + if err != nil || saved.Commands["help"] != 1 { + t.Fatalf("legacy meta command data = %#v, %v; want it left on disk", saved.Commands, err) + } +} + func TestProfileRenameRejectsTerminalControlsWithoutChangingTheSave(t *testing.T) { store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") player := profile.New("alex") diff --git a/internal/cli/show.go b/internal/cli/show.go index 60f8c3f..0393dc0 100644 --- a/internal/cli/show.go +++ b/internal/cli/show.go @@ -9,10 +9,11 @@ import ( func (a *App) runShow(args []string) error { flags := a.newFlagSet("show", func() { fmt.Fprintln(a.errOut, "Usage: opsquest show [MISSION]") }) - if help, err := parseFlags(flags, args); help || err != nil { + positionals, help, err := parseInterspersedFlags(flags, args) + if help || err != nil { return err } - if flags.NArg() > 1 { + if len(positionals) > 1 { return fmt.Errorf("usage: opsquest show [MISSION]") } player, err := a.loadPlayer() @@ -21,10 +22,10 @@ func (a *App) runShow(args []string) error { } var item mission.Mission var found bool - if flags.NArg() == 1 { - item, found = a.catalog.Find(flags.Arg(0)) + if len(positionals) == 1 { + item, found = a.catalog.Find(positionals[0]) if !found { - return fmt.Errorf("mission %q not found; run 'opsquest list'", flags.Arg(0)) + return fmt.Errorf("mission %q not found; run 'opsquest list'", positionals[0]) } } else { item, found = a.catalog.NextInTrack(mission.TrackLinux, player.IsComplete) diff --git a/internal/game/mission_navigation.go b/internal/game/mission_navigation.go index 618c714..ece341c 100644 --- a/internal/game/mission_navigation.go +++ b/internal/game/mission_navigation.go @@ -178,6 +178,9 @@ func (a *attempt) switchWorld(fields []string) step { fmt.Fprintln(a.Out, a.Style.Accent(message)) return step{} } + if !a.canSwitchTo(target) { + return step{} + } if replayingCompletedWorld { fmt.Fprintln(a.Out, a.Style.Accent(fmt.Sprintf("World %d is complete; replaying Stage 1.", worldNumber))) } @@ -217,6 +220,9 @@ func (a *attempt) switchMission(fields []string) step { fmt.Fprintln(a.Out, a.Style.Accent(fmt.Sprintf("Already playing Mission %02d: %s.", target.Number, target.Title))) return step{} } + if !a.canSwitchTo(target) { + return step{} + } printMissionSwitch(a.Out, target, a.Style) return finish(SessionResult{SwitchMission: target.ID, HintsUsed: a.hintsUsed}) } @@ -235,6 +241,22 @@ func (a *attempt) switchAdjacent(fields []string) step { fmt.Fprintln(a.Out, a.Style.Warning(fmt.Sprintf("Mission %02d is already at this end of the catalog.", a.Mission.Number))) return step{} } + if !a.canSwitchTo(target) { + return step{} + } printMissionSwitch(a.Out, target, a.Style) return finish(SessionResult{SwitchMission: target.ID, HintsUsed: a.hintsUsed}) } + +// canSwitchTo checks that target's environment can start before this attempt +// ends, so an unavailable Docker lab keeps the player in the current mission +// instead of tearing it down and failing the whole run. +func (a *attempt) canSwitchTo(target mission.Mission) bool { + availability := EnvironmentAvailability(a.ctx, a.factory, target) + if availability.Available { + return true + } + detail := strings.TrimSuffix(strings.TrimSpace(availability.Detail), ".") + a.fail(fmt.Sprintf("Mission %02d: %s cannot start: %s. You are still in Mission %02d.", target.Number, target.Title, detail, a.Mission.Number)) + return false +} diff --git a/internal/game/progression.go b/internal/game/progression.go index 61dbdfe..6474921 100644 --- a/internal/game/progression.go +++ b/internal/game/progression.go @@ -1,6 +1,7 @@ package game import ( + "slices" "time" "github.com/aleksandergregersen/opsquest/internal/mission" @@ -55,10 +56,25 @@ func ReconcileAchievements(player *profile.Profile, catalog mission.Catalog, now return unlocked } +// metaCommands explain or tidy the lab rather than practice an operations +// skill, so they never earn command mastery. +var metaCommands = map[string]bool{"clear": true, "help": true, "history": true, "man": true} + +// CountsAsPractice reports whether a successful command earns command mastery. +func CountsAsPractice(command string) bool { return !metaCommands[command] } + +// PracticedCommands returns the player's mastered commands in sorted order. +// Meta commands recorded by earlier builds remain on disk but are ignored. +func PracticedCommands(player profile.Profile) []string { + return slices.DeleteFunc(player.MasteredCommands(), func(command string) bool { + return !CountsAsPractice(command) + }) +} + // ReconcileCommandAchievements applies criteria that can change after an // ordinary successful command without traversing mission content. func ReconcileCommandAchievements(player *profile.Profile, now time.Time) []profile.Achievement { - if player == nil || len(player.Commands) < 10 { + if player == nil || len(PracticedCommands(*player)) < 10 { return nil } achievement, added := player.UnlockAchievement(profile.AchievementCommandCollector, now) diff --git a/internal/game/progression_test.go b/internal/game/progression_test.go index 5ab424b..6916694 100644 --- a/internal/game/progression_test.go +++ b/internal/game/progression_test.go @@ -1,6 +1,9 @@ package game import ( + "bytes" + "path/filepath" + "strings" "testing" "time" @@ -95,3 +98,53 @@ func TestReconcileAchievementsUsesDurableProfileAndCatalogState(t *testing.T) { t.Fatal("event-only pipeline achievement was inferred from durable state") } } + +func TestCommandCollectorIgnoresMetaCommands(t *testing.T) { + now := time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC) + player := profile.New("tester") + // An older build recorded these lab utilities as practice. + player.RecordCommands([]string{"clear", "help", "history", "man"}) + player.RecordCommands([]string{"cat", "cd", "cp", "echo", "find", "grep", "ls", "mkdir", "pwd"}) + + if got := PracticedCommands(player); len(got) != 9 || got[0] != "cat" || got[8] != "pwd" { + t.Fatalf("PracticedCommands() = %v, want the nine sorted teaching commands", got) + } + if unlocked := ReconcileCommandAchievements(&player, now); len(unlocked) != 0 { + t.Fatalf("meta commands completed Command Collector: %#v", unlocked) + } + player.RecordCommands([]string{"sort"}) + if unlocked := ReconcileCommandAchievements(&player, now); len(unlocked) != 1 || unlocked[0].ID != profile.AchievementCommandCollector { + t.Fatalf("tenth teaching command unlocked %#v, want Command Collector", unlocked) + } +} + +func TestSessionDoesNotRecordMetaCommandsAsPractice(t *testing.T) { + catalog, item := seamCatalogMission(t, "linux-orientation") + out := &bytes.Buffer{} + player := profile.New("tester") + session := Session{ + Mission: item, + Player: &player, + Saver: profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "tester"), + Out: out, + ErrOut: &bytes.Buffer{}, + Reader: &seamReader{lines: []string{"help", "man ls", "clear", "history", "ls", "pwd"}}, + Catalog: catalog, + } + + result, err := session.Run() + if err != nil || !result.Completed { + t.Fatalf("Run() = %#v, %v; want a completed mission", result, err) + } + for _, meta := range []string{"clear", "help", "history", "man"} { + if player.Commands[meta] != 0 { + t.Errorf("meta command %q recorded as practice: %#v", meta, player.Commands) + } + } + if player.Commands["ls"] != 1 || player.Commands["pwd"] != 1 { + t.Fatalf("teaching commands = %#v, want ls and pwd recorded", player.Commands) + } + if !strings.Contains(out.String(), "New commands discovered: ls, pwd\n") { + t.Fatalf("completion did not list only teaching commands:\n%s", out.String()) + } +} diff --git a/internal/game/session_commands.go b/internal/game/session_commands.go index 073d8b3..647cef1 100644 --- a/internal/game/session_commands.go +++ b/internal/game/session_commands.go @@ -7,6 +7,9 @@ import "fmt" func (a *attempt) revealHint() error { if a.hintsUsed >= len(a.Mission.Hints) { fmt.Fprintln(a.Out, a.Style.Warning("No more hints. ByteWorks has exhausted its documentation budget.")) + if !a.Companion { + printRevealedHints(a.Out, a.Mission, a.hintsUsed, a.Style) + } return nil } cost := 0 diff --git a/internal/game/session_execute.go b/internal/game/session_execute.go index cb9113b..8f1e586 100644 --- a/internal/game/session_execute.go +++ b/internal/game/session_execute.go @@ -81,7 +81,10 @@ func (a *attempt) runCommand(line string) (Execution, bool, error) { // recordPractice credits the commands a successful execution used, unlocks // command achievements, and saves the profile. func (a *attempt) recordPractice(result Execution) ([]profile.Achievement, error) { - for _, command := range result.PracticedCommands { + practiced := slices.DeleteFunc(slices.Clone(result.PracticedCommands), func(command string) bool { + return !CountsAsPractice(command) + }) + for _, command := range practiced { if !slices.Contains(a.practiced, command) { a.practiced = append(a.practiced, command) } @@ -89,7 +92,7 @@ func (a *attempt) recordPractice(result Execution) ([]profile.Achievement, error a.discovered = append(a.discovered, command) } } - a.Player.RecordCommands(result.PracticedCommands) + a.Player.RecordCommands(practiced) unlocked := make([]profile.Achievement, 0) achievementTime := a.Now() if result.PipelineWidth >= 3 { diff --git a/internal/game/session_lifecycle_test.go b/internal/game/session_lifecycle_test.go index 8251912..c72ee90 100644 --- a/internal/game/session_lifecycle_test.go +++ b/internal/game/session_lifecycle_test.go @@ -308,3 +308,49 @@ func TestDeferredCleanupFailureIsReturnedOnce(t *testing.T) { t.Fatalf("Close() calls = %d, want 1", environment.closeCount) } } + +func TestSessionStaysInCurrentLabWhenSwitchTargetIsUnavailable(t *testing.T) { + catalog, first := seamCatalogMission(t, "1") + environment := &seamEnvironment{prompt: "/test"} + created := 0 + factory := unavailableFactory{ + Factory: FactoryFunc(func(context.Context, mission.Mission) (Environment, error) { + created++ + return environment, nil + }), + availability: Availability{Detail: "Docker labs unavailable: docker executable not found in PATH."}, + } + errOut := &bytes.Buffer{} + player := profile.New("tester") + session := Session{ + Mission: first, + Player: &player, + Saver: profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "tester"), + Out: &bytes.Buffer{}, + ErrOut: errOut, + Reader: &seamReader{lines: []string{"play docker-container-census", "next", "world 2", "quit"}}, + Catalog: catalog, + Factory: factory, + } + + result, err := session.Run() + if err != nil { + t.Fatal(err) + } + if !result.Quit || result.SwitchMission != "" || result.WorldRoute != 0 { + t.Fatalf("result = %#v, want a quit from the original mission", result) + } + if created != 1 || environment.closeCount != 1 { + t.Fatalf("Create() calls = %d, Close() calls = %d; want the original lab kept until quit", created, environment.closeCount) + } + messages := errOut.String() + for _, want := range []string{ + "Mission 20: Container Census cannot start: Docker labs unavailable: docker executable not found in PATH. You are still in Mission 01.", + "Mission 02: Configuration Crawl cannot start", + "Mission 07: Permission to Deploy cannot start", + } { + if !strings.Contains(messages, want) { + t.Errorf("error output lacks %q:\n%s", want, messages) + } + } +} diff --git a/internal/game/session_output.go b/internal/game/session_output.go index 3ff29c3..8d073db 100644 --- a/internal/game/session_output.go +++ b/internal/game/session_output.go @@ -33,9 +33,28 @@ func printMission(out io.Writer, item mission.Mission, hintsUsed int, completed fmt.Fprintf(out, "\n%s\n%s\n\n", style.Section("INCIDENT"), item.Story) fmt.Fprintf(out, "%s\n%s\n\n", style.Section("OBJECTIVE"), item.Objective) fmt.Fprintf(out, "%s\n\n", style.CommandGuide(item.SuggestedCommands)) + if printRevealedHints(out, item, hintsUsed, style) { + fmt.Fprintln(out) + } printCompactMissionControls(out, style) } +// printRevealedHints repeats hints the player has already paid for, so a +// resumed or exhausted attempt can reread them without another XP cost. It +// reports whether anything was printed. +func printRevealedHints(out io.Writer, item mission.Mission, revealed int, style ui.Style) bool { + revealed = min(revealed, len(item.Hints)) + if revealed <= 0 { + return false + } + fmt.Fprintln(out, style.Section("REVEALED HINTS")+" "+style.Muted("(no extra cost)")) + for index := range revealed { + label := style.Warning(fmt.Sprintf("Hint %d/%d:", index+1, len(item.Hints))) + fmt.Fprintf(out, " %s %s\n", label, item.Hints[index]) + } + return true +} + func displayTrackName(track string) string { switch track { case mission.TrackDocker: diff --git a/internal/profile/profile.go b/internal/profile/profile.go index 978da8e..7dccdce 100644 --- a/internal/profile/profile.go +++ b/internal/profile/profile.go @@ -50,8 +50,21 @@ func (p *Profile) Normalize() { if p.Version < currentVersion { p.Version = currentVersion } + // Counters are never negative in profiles OpsQuest writes; clamp hand-edited + // values so levels, ranks, and mastery cannot display below zero. + p.XP = max(p.XP, 0) ensureMap(&p.Completed) + for missionID, completion := range p.Completed { + completion.XP = max(completion.XP, 0) + completion.HintsUsed = max(completion.HintsUsed, 0) + p.Completed[missionID] = completion + } ensureMap(&p.Commands) + for command, count := range p.Commands { + if count <= 0 { + delete(p.Commands, command) + } + } ensureMap(&p.Hints) for missionID, count := range p.Hints { if count < 0 || p.IsComplete(missionID) { diff --git a/internal/profile/profile_test.go b/internal/profile/profile_test.go index ce76b92..02150af 100644 --- a/internal/profile/profile_test.go +++ b/internal/profile/profile_test.go @@ -215,6 +215,52 @@ func TestNormalizeRemovesNegativeHintProgress(t *testing.T) { } } +func TestNormalizeClampsNegativeCounters(t *testing.T) { + player := New("alex") + player.XP = -250 + player.Completed["edited"] = Completion{XP: -40, HintsUsed: -1, CompletedAt: time.Unix(1, 0)} + player.Completed["valid"] = Completion{XP: 30, HintsUsed: 1, CompletedAt: time.Unix(1, 0)} + player.Commands["ls"] = -3 + player.Commands["pwd"] = 0 + player.Commands["cat"] = 2 + + player.Normalize() + + if player.XP != 0 || player.Level() != 1 { + t.Fatalf("XP = %d, level = %d; want 0 and level 1", player.XP, player.Level()) + } + if got := player.Completed["edited"]; got.XP != 0 || got.HintsUsed != 0 { + t.Fatalf("edited completion = %#v, want clamped counters", got) + } + if got := player.Completed["valid"]; got.XP != 30 || got.HintsUsed != 1 { + t.Fatalf("valid completion = %#v, want it unchanged", got) + } + if len(player.Commands) != 1 || player.Commands["cat"] != 2 { + t.Fatalf("commands = %#v, want only positive mastery counts", player.Commands) + } +} + +func TestLoadErrorsNameTheProfileAndRecovery(t *testing.T) { + path := filepath.Join(t.TempDir(), "profile.json") + store := NewStore(path, "alex") + + if err := os.WriteFile(path, nil, 0o600); err != nil { + t.Fatal(err) + } + _, err := store.Load() + if err == nil || !strings.Contains(err.Error(), "decode profile "+path) || !strings.Contains(err.Error(), "opsquest reset") { + t.Fatalf("empty profile error = %v, want the path and a recovery step", err) + } + + if err := os.WriteFile(path, []byte(`{"version": 99}`), 0o600); err != nil { + t.Fatal(err) + } + _, err = store.Load() + if err == nil || !strings.Contains(err.Error(), path) || !strings.Contains(err.Error(), "newer than this OpsQuest build") || strings.Contains(err.Error(), "reset") { + t.Fatalf("newer profile error = %v, want the path without suggesting a destructive reset", err) + } +} + func TestNormalizeRemovesLegacyHintProgressForCompletedMissions(t *testing.T) { player := New("operator") player.Completed["completed"] = Completion{XP: 25, HintsUsed: 1, CompletedAt: time.Unix(1, 0)} diff --git a/internal/profile/store.go b/internal/profile/store.go index d807ce1..4ba2bd4 100644 --- a/internal/profile/store.go +++ b/internal/profile/store.go @@ -49,14 +49,14 @@ func (s Store) Load() (Profile, error) { return New(s.name()), nil } if err != nil { - return Profile{}, fmt.Errorf("read profile: %w", err) + return Profile{}, fmt.Errorf("read profile %s: %w", s.path, err) } var player Profile if err := json.Unmarshal(data, &player); err != nil { - return Profile{}, fmt.Errorf("decode profile: %w", err) + return Profile{}, fmt.Errorf("decode profile %s: %w; move the file aside to keep a copy, or run 'opsquest reset' to start over", s.path, err) } if player.Version > currentVersion { - return Profile{}, fmt.Errorf("profile version %d is newer than this OpsQuest build", player.Version) + return Profile{}, fmt.Errorf("profile version %d at %s is newer than this OpsQuest build; upgrade OpsQuest to keep this progress", player.Version, s.path) } player.Normalize() return player, nil diff --git a/internal/sandbox/commands_archive.go b/internal/sandbox/commands_archive.go index 6b27cb2..ef68b39 100644 --- a/internal/sandbox/commands_archive.go +++ b/internal/sandbox/commands_archive.go @@ -217,12 +217,22 @@ func safeArchivePath(name string) (string, error) { } func (s *Sandbox) cmdGzip(command string, args []string) (string, error) { + args, err := operandsOnly(args) + if err != nil { + return "", err + } if len(args) == 0 { return "", fmt.Errorf("missing file operand") } for _, name := range args { source := s.Resolve(name) + if s.FS.IsDir(source) { + return "", fmt.Errorf("%s: is a directory", name) + } target := source + ".gz" + if command == "gzip" && strings.HasSuffix(source, ".gz") { + return "", fmt.Errorf("%s: already has .gz suffix", name) + } if command == "gunzip" { if !strings.HasSuffix(source, ".gz") { return "", fmt.Errorf("%s: filename does not end in .gz", name) diff --git a/internal/sandbox/commands_files.go b/internal/sandbox/commands_files.go index 7d55b73..fb16065 100644 --- a/internal/sandbox/commands_files.go +++ b/internal/sandbox/commands_files.go @@ -144,6 +144,10 @@ func (s *Sandbox) cmdMkdir(args []string) (string, error) { } func (s *Sandbox) cmdTouch(args []string) (string, error) { + args, err := operandsOnly(args) + if err != nil { + return "", err + } if len(args) == 0 { return "", fmt.Errorf("missing file operand") } @@ -197,7 +201,10 @@ func (s *Sandbox) cmdCopy(args []string) (string, error) { } func (s *Sandbox) cmdMove(args []string) (string, error) { - operands := args + operands, err := operandsOnly(args) + if err != nil { + return "", err + } if len(operands) < 2 { return "", fmt.Errorf("missing source or destination") } @@ -252,6 +259,9 @@ func (s *Sandbox) cmdRemove(command string, args []string) (string, error) { if command == "rmdir" && !s.FS.IsDir(resolved) { return "", fmt.Errorf("%s: not a directory", name) } + if command == "rm" && !recursive && s.FS.IsDir(resolved) { + return "", fmt.Errorf("%s: is a directory; use rm -r or rmdir", name) + } if err := s.FS.Remove(resolved, recursive, force); err != nil { return "", err } diff --git a/internal/sandbox/commands_find.go b/internal/sandbox/commands_find.go index 145022d..0ce0f5c 100644 --- a/internal/sandbox/commands_find.go +++ b/internal/sandbox/commands_find.go @@ -81,7 +81,7 @@ func (s *Sandbox) cmdFind(context *executionContext, args []string) (string, err if caseInsensitive { candidateName, pattern = strings.ToLower(candidateName), strings.ToLower(pattern) } - matched, err := path.Match(pattern, candidateName) + matched, err := matchShellPattern(pattern, candidateName) if err != nil { return "", fmt.Errorf("invalid name pattern: %w", err) } diff --git a/internal/sandbox/commands_gnu_fidelity_test.go b/internal/sandbox/commands_gnu_fidelity_test.go new file mode 100644 index 0000000..d6a7e26 --- /dev/null +++ b/internal/sandbox/commands_gnu_fidelity_test.go @@ -0,0 +1,264 @@ +package sandbox + +import ( + "strings" + "testing" +) + +// gnuFidelityCase runs setup lines, then line, and compares the result with +// what GNU coreutils and bash print for the same input. A non-empty wantErr +// expects an error containing that substring instead. +type gnuFidelityCase struct { + name string + setup []string + line string + want string + wantErr string +} + +func runGNUFidelityCases(t *testing.T, tests []gnuFidelityCase) { + t.Helper() + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + box := testSandbox(t) + for _, line := range test.setup { + if _, err := box.Execute(line); err != nil { + t.Fatalf("setup %q error = %v", line, err) + } + } + result, err := box.Execute(test.line) + if test.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("Execute(%q) error = %v, want substring %q", test.line, err, test.wantErr) + } + return + } + if err != nil { + t.Fatalf("Execute(%q) error = %v", test.line, err) + } + if result.Output != test.want { + t.Errorf("Execute(%q) output = %q, want %q", test.line, result.Output, test.want) + } + }) + } +} + +const fourLines = `printf 'h1\nl2\nl3\nl4\n' > /out/four.txt` + +func TestDoubleQuotesKeepBackslashesBeforeOrdinaryCharacters(t *testing.T) { + runGNUFidelityCases(t, []gnuFidelityCase{ + {name: "printf escapes survive", line: `printf "a\nb\n"`, want: "a\nb\n"}, + {name: "echo keeps backslash", line: `echo "x\ty"`, want: `x\ty` + "\n"}, + {name: "special characters still escape", line: "echo \"\\$HOME \\\\ \\\" \\`\"", want: "$HOME \\ \" `\n"}, + {name: "grep sees escaped dot", setup: []string{`printf 'a.log\naxlog\n' > /out/names.txt`}, line: `grep "a\.log" /out/names.txt`, want: "a.log\n"}, + {name: "tr deletes newlines", line: `printf 'a\nb\n' | tr -d "\n"`, want: "ab"}, + {name: "unterminated escape", line: `echo "abc\`, wantErr: "unfinished escape"}, + }) +} + +func TestHeadTailSignedCounts(t *testing.T) { + runGNUFidelityCases(t, []gnuFidelityCase{ + {name: "tail from line", setup: []string{fourLines}, line: "tail -n +2 /out/four.txt", want: "l2\nl3\nl4\n"}, + {name: "tail attached from line", setup: []string{fourLines}, line: "tail -n+4 /out/four.txt", want: "l4\n"}, + {name: "tail from zero is everything", setup: []string{fourLines}, line: "tail -n +0 /out/four.txt", want: "h1\nl2\nl3\nl4\n"}, + {name: "tail from past end", setup: []string{fourLines}, line: "tail -n +9 /out/four.txt", want: ""}, + {name: "tail minus counts from end", setup: []string{fourLines}, line: "tail -n -1 /out/four.txt", want: "l4\n"}, + {name: "head all but last", setup: []string{fourLines}, line: "head -n -1 /out/four.txt", want: "h1\nl2\nl3\n"}, + {name: "head all but more than all", setup: []string{fourLines}, line: "head -n -9 /out/four.txt", want: ""}, + {name: "head attached count", setup: []string{fourLines}, line: "head -n2 /out/four.txt", want: "h1\nl2\n"}, + {name: "double sign", setup: []string{fourLines}, line: "tail -n ++2 /out/four.txt", wantErr: "invalid line count"}, + {name: "not a number", setup: []string{fourLines}, line: "head -n two /out/four.txt", wantErr: "invalid line count"}, + }) +} + +func TestSortNumericUsesLeadingNumber(t *testing.T) { + runGNUFidelityCases(t, []gnuFidelityCase{ + {name: "units", line: `printf '10ms\n9ms\n100ms\n2ms\n' | sort -n`, want: "2ms\n9ms\n10ms\n100ms\n"}, + {name: "signs decimals and blanks", line: `printf ' 3.5 x\n-1.25\n3\n.5\n' | sort -n`, want: "-1.25\n.5\n3\n 3.5 x\n"}, + {name: "non-numeric lines are zero", line: `printf '5\nalpha\n-2\n' | sort -n`, want: "-2\nalpha\n5\n"}, + {name: "exponent is not numeric", line: `printf '1e3\n20\n' | sort -n`, want: "1e3\n20\n"}, + {name: "reverse", line: `printf '10ms\n9ms\n100ms\n' | sort -rn`, want: "100ms\n10ms\n9ms\n"}, + {name: "unique by numeric key", line: `printf '2 b\n1 x\n2 a\n' | sort -nu`, want: "1 x\n2 a\n"}, + }) +} + +func TestWCMatchesGNUCountsAndLayout(t *testing.T) { + runGNUFidelityCases(t, []gnuFidelityCase{ + {name: "all counts by default", setup: []string{fourLines}, line: "wc /out/four.txt", want: " 4 4 12 /out/four.txt\n"}, + {name: "combined flags", setup: []string{fourLines}, line: "wc -lw /out/four.txt", want: " 4 4 /out/four.txt\n"}, + {name: "separate flags keep fixed order", setup: []string{fourLines}, line: "wc -c -l /out/four.txt", want: " 4 12 /out/four.txt\n"}, + {name: "single count is unpadded", setup: []string{fourLines}, line: "wc -l /out/four.txt", want: "4 /out/four.txt\n"}, + {name: "multiple files add total", setup: []string{fourLines}, line: "wc -l /out/four.txt events.log", want: " 4 /out/four.txt\n 3 events.log\n 7 total\n"}, + {name: "standard input default width", line: `printf 'a b\n' | wc`, want: " 1 2 4\n"}, + {name: "standard input single count", line: "grep ERROR events.log | wc -l", want: "2\n"}, + {name: "unknown option", line: "wc -m events.log", wantErr: "unknown option -m"}, + }) +} + +func TestPrintfDirectivesAndFormatReuse(t *testing.T) { + runGNUFidelityCases(t, []gnuFidelityCase{ + {name: "format reuse", line: `printf '%s\n' a b c`, want: "a\nb\nc\n"}, + {name: "partial final pass", line: `printf '%s=%s\n' a 1 b`, want: "a=1\nb=\n"}, + {name: "no directives ignores values", line: `printf 'ready\n' extra`, want: "ready\n"}, + {name: "integers", line: `printf '%d items, %i left\n' 5 -2`, want: "5 items, -2 left\n"}, + {name: "missing integer is zero", line: `printf '[%d]\n'`, want: "[0]\n"}, + {name: "width precision and flags", line: `printf '%-6s|%3d|%05d|%.2s\n' ab 7 42 xyz`, want: "ab | 7|00042|xy\n"}, + {name: "percent literal", line: `printf '100%%\n'`, want: "100%\n"}, + {name: "unsupported conversion", line: `printf '%f\n' 1`, wantErr: `unsupported directive "%f"`}, + {name: "malformed flags", line: `printf '%1.2.3s\n' a`, wantErr: "unsupported directive"}, + {name: "invalid integer", line: `printf '%d\n' five`, wantErr: `"five": invalid number`}, + {name: "trailing percent", line: `printf '100%'`, wantErr: "missing conversion character"}, + {name: "oversized width", line: `printf '%999999999s'`, wantErr: "field limit"}, + {name: "oversized precision", line: `printf '%.999999999d' 1`, wantErr: "field limit"}, + }) +} + +func TestTrCharacterClassesAndOperands(t *testing.T) { + runGNUFidelityCases(t, []gnuFidelityCase{ + {name: "upper case", setup: []string{fourLines}, line: "tr '[:lower:]' '[:upper:]' < /out/four.txt", want: "H1\nL2\nL3\nL4\n"}, + {name: "delete digits", setup: []string{fourLines}, line: "tr -d '[:digit:]' < /out/four.txt", want: "h\nl\nl\nl\n"}, + {name: "squeeze spaces", line: `printf 'a \t b\n' | tr -s '[:blank:]'`, want: "a \t b\n"}, + {name: "classes mix with ranges", line: `printf 'Ab9-\n' | tr -d '[:upper:]0-9'`, want: "b-\n"}, + {name: "delete then squeeze second set", line: `printf 'axx b c\n' | tr -ds x ' '`, want: "a b c\n"}, + {name: "translate then squeeze", line: `printf 'aabbcc\n' | tr -s ab xx`, want: "xcc\n"}, + {name: "unknown class", line: `printf 'a\n' | tr -d '[:vowel:]'`, wantErr: "unknown character class [:vowel:]"}, + {name: "delete extra operand", line: `printf 'a\n' | tr -d a b`, wantErr: "usage: tr"}, + {name: "delete squeeze needs two sets", line: `printf 'a\n' | tr -ds a`, wantErr: "usage: tr"}, + {name: "translate needs two sets", line: `printf 'a\n' | tr a`, wantErr: "usage: tr"}, + }) +} + +func TestGrepUsesBasicRegexUnlessExtended(t *testing.T) { + logs := `printf 'ERROR x\nWARN y\nINFO (z)\na|b\n2^3 $5\n*star\n' > /out/mixed.log` + runGNUFidelityCases(t, []gnuFidelityCase{ + {name: "escaped alternation", setup: []string{logs}, line: `grep 'ERROR\|WARN' /out/mixed.log`, want: "ERROR x\nWARN y\n"}, + {name: "bare bar is literal", setup: []string{logs}, line: `grep 'a|b' /out/mixed.log`, want: "a|b\n"}, + {name: "extended alternation", setup: []string{logs}, line: `grep -E 'ERROR|WARN' /out/mixed.log`, want: "ERROR x\nWARN y\n"}, + {name: "escaped plus", setup: []string{logs}, line: `grep -c '^[A-Z]\+ ' /out/mixed.log`, want: "3\n"}, + {name: "bare plus is literal", setup: []string{logs}, line: `grep -c 'R+' /out/mixed.log`, want: "0\n"}, + {name: "bare parentheses are literal", setup: []string{logs}, line: `grep '(z)' /out/mixed.log`, want: "INFO (z)\n"}, + {name: "escaped group with interval", setup: []string{logs}, line: `grep '\(R\)\{2\}' /out/mixed.log`, want: "ERROR x\n"}, + {name: "leading star is literal", setup: []string{logs}, line: `grep '*star' /out/mixed.log`, want: "*star\n"}, + {name: "star after anchor is literal", setup: []string{logs}, line: `grep '^*' /out/mixed.log`, want: "*star\n"}, + {name: "inner caret and dollar are literal", setup: []string{logs}, line: `grep '2^3 $5' /out/mixed.log`, want: "2^3 $5\n"}, + {name: "bracket classes", setup: []string{logs}, line: `grep '^[[:upper:]]\{4\} [a-z]$' /out/mixed.log`, want: "WARN y\n"}, + {name: "bracket with leading close", setup: []string{logs}, line: `grep -c '[]|]' /out/mixed.log`, want: "1\n"}, + {name: "word boundary", setup: []string{logs}, line: `grep '\' /out/mixed.log`, want: "WARN y\n"}, + {name: "backreference", setup: []string{logs}, line: `grep '\(a\)\1' /out/mixed.log`, wantErr: "backreferences"}, + {name: "unterminated bracket", setup: []string{logs}, line: `grep '[abc' /out/mixed.log`, wantErr: "unterminated bracket"}, + }) +} + +func TestSedReplacementSyntax(t *testing.T) { + config := `printf 'LOG_LEVEL=debug\npath=/var/log\n' > /out/app.env` + runGNUFidelityCases(t, []gnuFidelityCase{ + {name: "ampersand is the match", setup: []string{config}, line: `sed 's/debug/[&]/' /out/app.env`, want: "LOG_LEVEL=[debug]\npath=/var/log\n"}, + {name: "escaped ampersand is literal", setup: []string{config}, line: `sed 's/debug/\&/' /out/app.env`, want: "LOG_LEVEL=&\npath=/var/log\n"}, + {name: "basic group reference", setup: []string{config}, line: `sed 's/\(LOG_LEVEL\)=debug/\1=info/' /out/app.env`, want: "LOG_LEVEL=info\npath=/var/log\n"}, + {name: "extended group reference", setup: []string{config}, line: `sed -E 's/(LOG)_(LEVEL)/\2_\1/' /out/app.env`, want: "LEVEL_LOG=debug\npath=/var/log\n"}, + {name: "dollar is literal", setup: []string{config}, line: `sed 's/debug/$1 cost/' /out/app.env`, want: "LOG_LEVEL=$1 cost\npath=/var/log\n"}, + {name: "escaped backslash", setup: []string{config}, line: `sed 's/debug/a\\b/' /out/app.env`, want: "LOG_LEVEL=a\\b\npath=/var/log\n"}, + {name: "backslash pattern", line: `printf 'a\\b\n' | sed 's/\\/X/'`, want: "aXb\n"}, + {name: "escaped delimiter", setup: []string{config}, line: `sed 's/\//_/g' /out/app.env`, want: "LOG_LEVEL=debug\npath=_var_log\n"}, + {name: "basic plus is literal", setup: []string{config}, line: `sed 's/[a-z]+/X/' /out/app.env`, want: "LOG_LEVEL=debug\npath=/var/log\n"}, + {name: "global ampersand", line: `printf 'ab\n' | sed 's/[ab]/&&/g'`, want: "aabb\n"}, + {name: "invalid group reference", setup: []string{config}, line: `sed 's/debug/\1/' /out/app.env`, wantErr: `invalid reference \1`}, + }) +} + +func TestShellGlobNegatedBrackets(t *testing.T) { + runGNUFidelityCases(t, []gnuFidelityCase{ + {name: "argument glob", line: "echo [!e]*.log", want: "quiet.log\n"}, + {name: "find name", line: "find . -type f -name '[!q]*'", want: "./events.log\n"}, + }) + + for _, test := range []struct { + pattern, name string + want bool + }{ + {pattern: "[!e]*", name: "quiet.log", want: true}, + {pattern: "[!e]*", name: "events.log", want: false}, + {pattern: `\[!x]`, name: "[!x]", want: true}, + {pattern: `\[!x]`, name: "a", want: false}, + } { + if got, err := matchShellPattern(test.pattern, test.name); err != nil || got != test.want { + t.Errorf("matchShellPattern(%q, %q) = %v, %v; want %v", test.pattern, test.name, got, err, test.want) + } + } +} + +func TestRemoveRefusesDirectoriesWithoutRecursion(t *testing.T) { + runGNUFidelityCases(t, []gnuFidelityCase{ + {name: "empty directory", setup: []string{"mkdir /out/empty"}, line: "rm /out/empty", wantErr: "is a directory"}, + {name: "force does not imply recursion", setup: []string{"mkdir /out/empty"}, line: "rm -f /out/empty", wantErr: "is a directory"}, + {name: "recursive removes", setup: []string{"mkdir /out/empty"}, line: "rm -r /out/empty", want: ""}, + {name: "rmdir removes", setup: []string{"mkdir /out/empty"}, line: "rmdir /out/empty", want: ""}, + }) + + box := testSandbox(t) + if _, err := box.Execute("mkdir /out/empty"); err != nil { + t.Fatal(err) + } + if _, err := box.Execute("rm /out/empty"); err == nil || !box.FS.IsDir("/out/empty") { + t.Fatalf("rm without -r removed a directory: err %v", err) + } +} + +func TestOptionlessCommandsRejectUnknownOptions(t *testing.T) { + runGNUFidelityCases(t, []gnuFidelityCase{ + {name: "cat", line: "cat -n events.log", wantErr: "unknown option -n"}, + {name: "less", line: "less -S events.log", wantErr: "unknown option -S"}, + {name: "touch", line: "touch -a /out/new.txt", wantErr: "unknown option -a"}, + {name: "mv", line: "mv -f events.log /out/", wantErr: "unknown option -f"}, + {name: "stat", line: "stat -c '%a' events.log", wantErr: "unknown option -c"}, + {name: "gzip", line: "gzip -d events.log", wantErr: "unknown option -d"}, + {name: "gunzip", line: "gunzip -k events.log.gz", wantErr: "unknown option -k"}, + {name: "gzip directory", line: "gzip /out", wantErr: "is a directory"}, + {name: "gzip compressed", setup: []string{"gzip quiet.log"}, line: "gzip quiet.log.gz", wantErr: "already has .gz suffix"}, + {name: "end of options", setup: []string{"touch -- -dash.txt"}, line: "cat -- -dash.txt", want: ""}, + }) + + box := testSandbox(t) + for _, line := range []string{"touch -a /out/new.txt", "mv -f events.log /out/"} { + if _, err := box.Execute(line); err == nil { + t.Fatalf("Execute(%q) unexpectedly succeeded", line) + } + } + if box.FS.Exists("/out/new.txt") || box.FS.Exists("/work/-a") || !box.FS.Exists("/work/events.log") { + t.Fatalf("rejected options changed the filesystem: %v", box.FS.Paths()) + } +} + +func TestTranslateBasicRegex(t *testing.T) { + tests := []struct { + basic string + want string + }{ + {basic: `ERROR\|WARN`, want: `ERROR|WARN`}, + {basic: `a|b+c?`, want: `a\|b\+c\?`}, + {basic: `\(ab\)\{2,3\}`, want: `(ab){2,3}`}, + {basic: `(x){y}`, want: `\(x\)\{y\}`}, + {basic: `*a`, want: `\*a`}, + {basic: `^*a`, want: `^\*a`}, + {basic: `\(*a\)`, want: `(\*a)`}, + {basic: `a^b`, want: `a\^b`}, + {basic: `^^`, want: `^\^`}, + {basic: `a$b$`, want: `a\$b$`}, + {basic: `\(a$\)`, want: `(a$)`}, + {basic: `[]a\]x`, want: `[]a\\]x`}, + {basic: `[^[:space:]]`, want: `[^[:space:]]`}, + {basic: `\.\*\/`, want: `\.\*\/`}, + {basic: `\`, want: `\bw\b`}, + } + for _, test := range tests { + got, err := translateBasicRegex(test.basic) + if err != nil || got != test.want { + t.Errorf("translateBasicRegex(%q) = %q, %v; want %q", test.basic, got, err, test.want) + } + } + for _, invalid := range []string{`a\`, `[abc`, `\(a\)\1`} { + if _, err := translateBasicRegex(invalid); err == nil { + t.Errorf("translateBasicRegex(%q) unexpectedly succeeded", invalid) + } + } +} diff --git a/internal/sandbox/commands_grep.go b/internal/sandbox/commands_grep.go index 4f45dbb..32def70 100644 --- a/internal/sandbox/commands_grep.go +++ b/internal/sandbox/commands_grep.go @@ -9,7 +9,7 @@ import ( func (s *Sandbox) cmdGrep(args []string, stdin string) (string, error) { recursive, namesOnly, lineNumbers := false, false, false - insensitive, invert, fixed, countOnly, wholeWord := false, false, false, false, false + insensitive, invert, fixed, countOnly, wholeWord, extended := false, false, false, false, false, false var operands []string optionsDone := false for _, arg := range args { @@ -37,7 +37,7 @@ func (s *Sandbox) cmdGrep(args []string, stdin string) (string, error) { case 'w': wholeWord = true case 'E': - // Go regular expressions already use extended-style syntax. + extended = true default: return "", fmt.Errorf("unknown option -%c", option) } @@ -53,6 +53,12 @@ func (s *Sandbox) cmdGrep(args []string, stdin string) (string, error) { pattern := operands[0] if fixed { pattern = regexp.QuoteMeta(pattern) + } else if !extended { + translated, err := translateBasicRegex(pattern) + if err != nil { + return "", fmt.Errorf("invalid pattern: %w", err) + } + pattern = translated } if wholeWord { pattern = `\b(?:` + pattern + `)\b` diff --git a/internal/sandbox/commands_inspect.go b/internal/sandbox/commands_inspect.go index aa0d419..6437e31 100644 --- a/internal/sandbox/commands_inspect.go +++ b/internal/sandbox/commands_inspect.go @@ -95,6 +95,10 @@ func humanSize(size int) string { } func (s *Sandbox) cmdStat(args []string) (string, error) { + args, err := operandsOnly(args) + if err != nil { + return "", err + } if len(args) == 0 { return "", fmt.Errorf("missing file operand") } diff --git a/internal/sandbox/commands_script.go b/internal/sandbox/commands_script.go index 7fe44b0..fea172c 100644 --- a/internal/sandbox/commands_script.go +++ b/internal/sandbox/commands_script.go @@ -105,9 +105,6 @@ func (s *Sandbox) executeScript(context *executionContext, scriptPath string, re if trimmed == "" || strings.HasPrefix(trimmed, "#") { continue } - if err := validateScriptSyntax(line); err != nil { - return "", fmt.Errorf("%s:%d: %w", scriptPath, lineNumber, err) - } result, err := s.executeLine(line, context, false) if err != nil { return "", fmt.Errorf("%s:%d: %w", scriptPath, lineNumber, err) @@ -120,76 +117,6 @@ func (s *Sandbox) executeScript(context *executionContext, scriptPath string, re return output.String(), nil } -func validateScriptSyntax(line string) error { - runes := []rune(line) - var quote rune - escaped := false - tokenStarted := false - for index, char := range runes { - if escaped { - escaped = false - tokenStarted = true - continue - } - if quote == '\'' { - if char == quote { - quote = 0 - } - continue - } - if char == '\\' { - escaped = true - continue - } - if quote == '"' { - if char == quote { - quote = 0 - continue - } - if char == '`' || char == '$' && index+1 < len(runes) && runes[index+1] == '(' { - return fmt.Errorf("command substitution is not supported") - } - if char == '$' && isUnsupportedScriptParameter(runes[index+1:]) { - return fmt.Errorf("positional and special parameters are not supported") - } - continue - } - - if unicode.IsSpace(char) { - tokenStarted = false - continue - } - if char == '#' && !tokenStarted { - break - } - if char == '\'' || char == '"' { - quote = char - tokenStarted = true - continue - } - switch char { - case ';', '&', '(', ')': - return fmt.Errorf("shell control syntax %q is not supported", char) - case '`': - return fmt.Errorf("command substitution is not supported") - case '|': - if index+1 < len(runes) && runes[index+1] == '|' { - return fmt.Errorf("control operator || is not supported") - } - case '$': - if index+1 < len(runes) && runes[index+1] == '(' { - return fmt.Errorf("command substitution is not supported") - } - if isUnsupportedScriptParameter(runes[index+1:]) { - return fmt.Errorf("positional and special parameters are not supported") - } - } - tokenStarted = true - } - - return nil -} - func validateScriptCommands(parsed commandLine) error { for _, stage := range parsed.stages { first := stage.args[0].value @@ -209,16 +136,6 @@ var unsupportedScriptKeywords = map[string]struct{}{ "case": {}, "esac": {}, "select": {}, "function": {}, } -func isUnsupportedScriptParameter(input []rune) bool { - if len(input) == 0 { - return false - } - if unicode.IsDigit(input[0]) || strings.ContainsRune("?$!#*@-", input[0]) { - return true - } - return len(input) > 1 && input[0] == '{' && (unicode.IsDigit(input[1]) || strings.ContainsRune("?$!#*@-", input[1])) -} - func validScriptVariableName(name string) bool { if name == "" { return false diff --git a/internal/sandbox/commands_sed.go b/internal/sandbox/commands_sed.go index b5469ca..ebd5402 100644 --- a/internal/sandbox/commands_sed.go +++ b/internal/sandbox/commands_sed.go @@ -16,7 +16,7 @@ type sedSubstitution struct { } func (s *Sandbox) cmdSed(args []string, stdin string) (string, error) { - inPlace, quiet := false, false + inPlace, quiet, extended := false, false, false expression := "" files := make([]string, 0) for index := 0; index < len(args); index++ { @@ -25,6 +25,8 @@ func (s *Sandbox) cmdSed(args []string, stdin string) (string, error) { inPlace = true case "-n": quiet = true + case "-E", "-r": + extended = true case "-e": if index+1 >= len(args) { return "", fmt.Errorf("-e requires an expression") @@ -51,7 +53,7 @@ func (s *Sandbox) cmdSed(args []string, stdin string) (string, error) { if inPlace && len(files) == 0 { return "", fmt.Errorf("-i requires at least one file") } - substitution, err := parseSedSubstitution(expression) + substitution, err := parseSedSubstitution(expression, extended) if err != nil { return "", err } @@ -84,7 +86,7 @@ func (s *Sandbox) cmdSed(args []string, stdin string) (string, error) { return output.Result() } -func parseSedSubstitution(expression string) (sedSubstitution, error) { +func parseSedSubstitution(expression string, extended bool) (sedSubstitution, error) { if len(expression) < 4 || expression[0] != 's' { return sedSubstitution{}, fmt.Errorf("this lab supports substitutions like s/old/new/g") } @@ -100,10 +102,20 @@ func parseSedSubstitution(expression string) (sedSubstitution, error) { if err != nil { return sedSubstitution{}, err } + if !extended { + pattern, err = translateBasicRegex(pattern) + if err != nil { + return sedSubstitution{}, fmt.Errorf("invalid regular expression: %w", err) + } + } matcher, err := regexp.Compile(pattern) if err != nil { return sedSubstitution{}, fmt.Errorf("invalid regular expression: %w", err) } + replacement, err = translateSedReplacement(replacement, matcher.NumSubexp()) + if err != nil { + return sedSubstitution{}, err + } result := sedSubstitution{pattern: matcher, replacement: replacement} for _, flag := range expression[next:] { switch flag { @@ -125,12 +137,13 @@ func readSedSection(expression string, start int, delimiter byte) (string, int, return section.String(), index + 1, nil } if expression[index] == '\\' && index+1 < len(expression) { - if expression[index+1] == delimiter { - section.WriteByte(delimiter) - index++ - continue + index++ + if expression[index] != delimiter { + // Keep other escapes, such as \\ or \(, intact for the regex and + // replacement translators; only an escaped delimiter is unwrapped. + section.WriteByte('\\') } - section.WriteByte('\\') + section.WriteByte(expression[index]) continue } section.WriteByte(expression[index]) diff --git a/internal/sandbox/commands_text.go b/internal/sandbox/commands_text.go index 529e19f..27bc29d 100644 --- a/internal/sandbox/commands_text.go +++ b/internal/sandbox/commands_text.go @@ -2,6 +2,7 @@ package sandbox import ( "fmt" + "regexp" "slices" "sort" "strconv" @@ -29,7 +30,11 @@ type namedText struct { } func (s *Sandbox) cmdCat(args []string, stdin string) (string, error) { - inputs, err := s.readInputs(args, stdin) + files, err := operandsOnly(args) + if err != nil { + return "", err + } + inputs, err := s.readInputs(files, stdin) if err != nil { return "", err } @@ -42,7 +47,24 @@ func (s *Sandbox) cmdCat(args []string, stdin string) (string, error) { func (s *Sandbox) cmdHeadTail(args []string, stdin string, head bool) (string, error) { count := 10 + // fromStart selects GNU's signed forms: tail -n +N starts at line N and + // head -n -N prints everything except the last N lines. + fromStart := false var files []string + setCount := func(value string) error { + sign := byte(0) + if value != "" && (value[0] == '+' || value[0] == '-') { + sign = value[0] + value = value[1:] + } + number, err := strconv.Atoi(value) + if err != nil || number < 0 || strings.ContainsAny(value, "+-") { + return fmt.Errorf("invalid line count %q", string(sign)+value) + } + count = number + fromStart = head && sign == '-' || !head && sign == '+' + return nil + } for index := 0; index < len(args); index++ { arg := args[index] switch { @@ -51,17 +73,20 @@ func (s *Sandbox) cmdHeadTail(args []string, stdin string, head bool) (string, e return "", fmt.Errorf("-n requires a line count") } index++ - value, err := strconv.Atoi(args[index]) - if err != nil || value < 0 { - return "", fmt.Errorf("invalid line count %q", args[index]) + if err := setCount(args[index]); err != nil { + return "", err + } + case strings.HasPrefix(arg, "-n"): + if err := setCount(strings.TrimPrefix(arg, "-n")); err != nil { + return "", err } - count = value case strings.HasPrefix(arg, "-") && len(arg) > 1: value, err := strconv.Atoi(strings.TrimPrefix(arg, "-")) if err != nil || value < 0 { return "", fmt.Errorf("unknown option %s", arg) } count = value + fromStart = false default: files = append(files, arg) } @@ -79,11 +104,15 @@ func (s *Sandbox) cmdHeadTail(args []string, stdin string, head bool) (string, e output.WriteString("==> " + input.name + " <==\n") } lines := textLines(input.text) - limit := min(count, len(lines)) - if head { - lines = lines[:limit] - } else { - lines = lines[len(lines)-limit:] + switch { + case head && fromStart: + lines = lines[:len(lines)-min(count, len(lines))] + case head: + lines = lines[:min(count, len(lines))] + case fromStart: + lines = lines[min(max(count-1, 0), len(lines)):] + default: + lines = lines[len(lines)-min(count, len(lines)):] } if len(lines) > 0 { output.WriteString(strings.Join(lines, "\n") + "\n") @@ -122,18 +151,43 @@ func (s *Sandbox) cmdSort(args []string, stdin string) (string, error) { if reverse { slices.Reverse(lines) } - if unique { + if unique && numeric { + // Like GNU sort, -nu keeps the first line of each equal numeric key. + lines = slices.CompactFunc(lines, func(left, right string) bool { + return leadingNumber(left) == leadingNumber(right) + }) + } else if unique { lines = slices.Compact(lines) } return joinOutputLines(lines), nil } +// leadingNumber reads the numeric prefix GNU sort -n compares: optional +// leading blanks, an optional minus sign, digits, and an optional decimal +// fraction. Text after the prefix is ignored, so "10ms" sorts as 10 and a +// line without a numeric prefix sorts as 0. func leadingNumber(value string) float64 { - fields := strings.Fields(value) - if len(fields) == 0 { + value = strings.TrimLeft(value, " \t") + end := 0 + if end < len(value) && value[end] == '-' { + end++ + } + digits := 0 + for end < len(value) && value[end] >= '0' && value[end] <= '9' { + end++ + digits++ + } + if end < len(value) && value[end] == '.' { + end++ + for end < len(value) && value[end] >= '0' && value[end] <= '9' { + end++ + digits++ + } + } + if digits == 0 { return 0 } - number, _ := strconv.ParseFloat(fields[0], 64) + number, _ := strconv.ParseFloat(strings.TrimSuffix(value[:end], "."), 64) return number } @@ -177,47 +231,69 @@ func (s *Sandbox) cmdUniq(args []string, stdin string) (string, error) { } func (s *Sandbox) cmdWC(args []string, stdin string) (string, error) { - mode := "lines" - var files []string - for _, arg := range args { - switch arg { - case "-l": - mode = "lines" - case "-w": - mode = "words" - case "-c": - mode = "bytes" - default: - if strings.HasPrefix(arg, "-") { - return "", fmt.Errorf("unknown option %s", arg) - } - files = append(files, arg) - } + options, files, err := parseShortOptions(args, "lwc", false) + if err != nil { + return "", err } + if options == "" { + options = "lwc" + } + selected := []bool{strings.ContainsRune(options, 'l'), strings.ContainsRune(options, 'w'), strings.ContainsRune(options, 'c')} inputs, err := s.readInputs(files, stdin) if err != nil { return "", err } - var output commandOutputBuffer + rows := make([]wcRow, 0, len(inputs)+1) + total := wcRow{name: "total"} for _, input := range inputs { - var count int - switch mode { - case "lines": - count = strings.Count(input.text, "\n") - case "words": - count = len(strings.Fields(input.text)) - case "bytes": - count = len(input.text) + row := wcRow{name: input.name, counts: [3]int{strings.Count(input.text, "\n"), len(strings.Fields(input.text)), len(input.text)}} + for index := range total.counts { + total.counts[index] += row.counts[index] } - output.WriteString(strconv.Itoa(count)) - if input.name != "" { - output.WriteString(" " + input.name) + rows = append(rows, row) + } + if len(rows) > 1 { + rows = append(rows, total) + } + + // GNU wc right-aligns every count to one width. A single count for a + // single input is unpadded; otherwise the width fits the total byte count + // of named files, with a minimum of 7 when reading standard input. + selectedCount := 0 + for _, enabled := range selected { + if enabled { + selectedCount++ + } + } + width := 1 + if selectedCount > 1 || len(inputs) > 1 { + width = len(strconv.Itoa(total.counts[2])) + if len(files) == 0 { + width = max(width, 7) + } + } + var output commandOutputBuffer + for _, row := range rows { + separator := "" + for index, enabled := range selected { + if enabled { + output.WriteString(fmt.Sprintf("%s%*d", separator, width, row.counts[index])) + separator = " " + } + } + if row.name != "" { + output.WriteString(" " + row.name) } output.WriteByte('\n') } return output.Result() } +type wcRow struct { + name string + counts [3]int +} + func cmdEcho(args []string) (string, error) { newline := true if len(args) > 0 && args[0] == "-n" { @@ -231,6 +307,12 @@ func cmdEcho(args []string) (string, error) { return result, nil } +// maxPrintfField bounds printf widths and precisions so a directive such as +// %999999999s fails before formatting allocates beyond the output budget. +const maxPrintfField = 4096 + +var printfSpecPattern = regexp.MustCompile(`^[-+0]*([0-9]*)(?:\.([0-9]*))?$`) + func cmdPrintf(args []string) (string, error) { if len(args) == 0 { return "", fmt.Errorf("missing format string") @@ -238,27 +320,74 @@ func cmdPrintf(args []string) (string, error) { format := strings.NewReplacer(`\n`, "\n", `\t`, "\t", `\\`, "\\").Replace(args[0]) values := args[1:] var output commandOutputBuffer - valueIndex := 0 + // Like POSIX printf, the format is reused while values remain, so + // printf '%s\n' a b c prints three lines. + for { + consumed, err := writePrintfFormat(&output, format, values) + if err != nil { + return "", err + } + values = values[consumed:] + if consumed == 0 || len(values) == 0 { + break + } + } + return output.Result() +} + +func writePrintfFormat(output *commandOutputBuffer, format string, values []string) (int, error) { + used := 0 + nextValue := func() (string, bool) { + if used >= len(values) { + return "", false + } + used++ + return values[used-1], true + } for index := 0; index < len(format); index++ { - if format[index] == '%' && index+1 < len(format) { - next := format[index+1] - if next == '%' { - output.WriteByte('%') - index++ - continue + if format[index] != '%' { + output.WriteByte(format[index]) + continue + } + end := index + 1 + for end < len(format) && strings.IndexByte("-+0123456789.", format[end]) >= 0 { + end++ + } + if end >= len(format) { + return used, fmt.Errorf("missing conversion character after %q", format[index:]) + } + spec, verb := format[index+1:end], format[end] + directive := format[index : end+1] + index = end + if verb == '%' && spec == "" { + output.WriteByte('%') + continue + } + match := printfSpecPattern.FindStringSubmatch(spec) + if verb != 's' && verb != 'd' && verb != 'i' || match == nil { + return used, fmt.Errorf("unsupported directive %q; use %%s, %%d, or %%%% with optional - or 0 flags, width, and precision", directive) + } + for _, field := range match[1:] { + if size, _ := strconv.Atoi(field); size > maxPrintfField { + return used, fmt.Errorf("%q exceeds the %d-character field limit", directive, maxPrintfField) } - if next == 's' { - if valueIndex < len(values) { - output.WriteString(values[valueIndex]) - valueIndex++ - } - index++ - continue + } + value, _ := nextValue() + if verb == 's' { + output.WriteString(fmt.Sprintf("%"+spec+"s", value)) + continue + } + number := int64(0) + if trimmed := strings.TrimSpace(value); trimmed != "" { + parsed, err := strconv.ParseInt(trimmed, 10, 64) + if err != nil { + return used, fmt.Errorf("%q: invalid number", value) } + number = parsed } - output.WriteByte(format[index]) + output.WriteString(fmt.Sprintf("%"+spec+"d", number)) } - return output.Result() + return used, output.Err() } func textLines(text string) []string { diff --git a/internal/sandbox/commands_tr.go b/internal/sandbox/commands_tr.go index 84da725..cfe10cd 100644 --- a/internal/sandbox/commands_tr.go +++ b/internal/sandbox/commands_tr.go @@ -11,8 +11,14 @@ func (s *Sandbox) cmdTr(args []string, stdin string) (string, error) { return "", err } deleteSet, squeeze := strings.ContainsRune(options, 'd'), strings.ContainsRune(options, 's') - if len(operands) == 0 || (!deleteSet && !squeeze && len(operands) < 2) || len(operands) > 2 { - return "", fmt.Errorf("usage: tr [-ds] SET1 [SET2]") + // GNU tr takes one set for -d or -s alone, two sets to translate, and two + // sets for -ds (delete SET1, then squeeze SET2). + wantOperands := 2 + if deleteSet != squeeze { + wantOperands = 1 + } + if len(operands) < wantOperands || len(operands) > 2 || deleteSet && !squeeze && len(operands) > 1 { + return "", fmt.Errorf("usage: tr SET1 SET2, tr -d SET1, tr -s SET1 [SET2], or tr -ds SET1 SET2") } set1, err := expandCharacterSet(operands[0]) if err != nil { @@ -35,7 +41,7 @@ func (s *Sandbox) cmdTr(args []string, stdin string) (string, error) { } } squeezeSet := set2 - if deleteSet || len(squeezeSet) == 0 { + if len(squeezeSet) == 0 { squeezeSet = set1 } squeezeMap := make(map[rune]bool, len(squeezeSet)) @@ -66,6 +72,14 @@ func expandCharacterSet(value string) ([]rune, error) { runes := []rune(value) result := make([]rune, 0, len(runes)) for index := 0; index < len(runes); index++ { + if class, length, found := characterClass(runes[index:]); found { + if class == nil { + return nil, fmt.Errorf("unknown character class %s", string(runes[index:index+length])) + } + result = append(result, class...) + index += length - 1 + continue + } if index+2 < len(runes) && runes[index+1] == '-' { if runes[index] > runes[index+2] { return nil, fmt.Errorf("descending character range %c-%c", runes[index], runes[index+2]) @@ -80,3 +94,50 @@ func expandCharacterSet(value string) ([]rune, error) { } return result, nil } + +// characterClass expands a POSIX [:name:] class at the start of input. found +// reports whether input starts with a complete [:...:] token; class is nil for +// an unknown name. Classes cover ASCII, matching the C locale. +func characterClass(input []rune) (class []rune, length int, found bool) { + if len(input) < 4 || input[0] != '[' || input[1] != ':' { + return nil, 0, false + } + for end := 2; end+1 < len(input); end++ { + if input[end] != ':' || input[end+1] != ']' { + continue + } + name := string(input[2:end]) + for char := rune(0); char < 128; char++ { + if asciiClassContains(name, char) { + class = append(class, char) + } + } + return class, end + 2, true + } + return nil, 0, false +} + +func asciiClassContains(name string, char rune) bool { + lower, upper, digit := 'a' <= char && char <= 'z', 'A' <= char && char <= 'Z', '0' <= char && char <= '9' + switch name { + case "alnum": + return lower || upper || digit + case "alpha": + return lower || upper + case "blank": + return char == ' ' || char == '\t' + case "digit": + return digit + case "lower": + return lower + case "upper": + return upper + case "punct": + return char > ' ' && char < 127 && !lower && !upper && !digit + case "space": + return strings.ContainsRune(" \t\n\v\f\r", char) + case "xdigit": + return digit || 'a' <= char && char <= 'f' || 'A' <= char && char <= 'F' + } + return false +} diff --git a/internal/sandbox/filesystem_queries.go b/internal/sandbox/filesystem_queries.go index 91f2b17..4b4ae99 100644 --- a/internal/sandbox/filesystem_queries.go +++ b/internal/sandbox/filesystem_queries.go @@ -62,7 +62,7 @@ func (f *FileSystem) Glob(cwd, pattern string) []string { absPattern := Clean(cwd, pattern) matches := make([]string, 0) for candidate := range f.entries { - matched, err := path.Match(absPattern, candidate) + matched, err := matchShellPattern(absPattern, candidate) if err == nil && matched { if strings.HasPrefix(pattern, "/") { matches = append(matches, candidate) @@ -79,6 +79,24 @@ func (f *FileSystem) Glob(cwd, pattern string) []string { return matches } +// matchShellPattern is path.Match with the shell's [!...] negated bracket +// spelling, which path.Match only understands as [^...]. +func matchShellPattern(pattern, name string) (bool, error) { + if strings.Contains(pattern, "[!") { + runes := []rune(pattern) + for index := 0; index < len(runes); index++ { + switch { + case runes[index] == '\\': + index++ + case runes[index] == '[' && index+1 < len(runes) && runes[index+1] == '!': + runes[index+1] = '^' + } + } + pattern = string(runes) + } + return path.Match(pattern, name) +} + func relativePath(base, target string) string { base, target = path.Clean(base), path.Clean(target) if target == base { diff --git a/internal/sandbox/filesystem_quota_test.go b/internal/sandbox/filesystem_quota_test.go index b3135c4..ce8befe 100644 --- a/internal/sandbox/filesystem_quota_test.go +++ b/internal/sandbox/filesystem_quota_test.go @@ -236,7 +236,7 @@ func TestMoveRejectsDestinationTreeBeyondPathLimitAtomically(t *testing.T) { func TestSedAmplificationIsRejectedBeforeInPlaceWrite(t *testing.T) { box := testSandbox(t) - result, err := box.Execute(`printf 'aa\n' | sed 's/(a+)/$1$1/'`) + result, err := box.Execute(`printf 'aa\n' | sed -E 's/(a+)/\1\1/'`) if err != nil || result.Output != "aaaa\n" { t.Fatalf("bounded capture replacement output = %q, error %v", result.Output, err) } @@ -248,7 +248,7 @@ func TestSedAmplificationIsRejectedBeforeInPlaceWrite(t *testing.T) { t.Fatal(err) } - if _, err := box.Execute(`sed -i 's/(a+)/$1$1/' large.txt`); err == nil { + if _, err := box.Execute(`sed -E -i 's/(a+)/\1\1/' large.txt`); err == nil { t.Error("amplifying sed replacement unexpectedly succeeded") } content, err := box.FS.ReadFile("/work/large.txt") diff --git a/internal/sandbox/hardening_test.go b/internal/sandbox/hardening_test.go index ca3af26..cdd4bd7 100644 --- a/internal/sandbox/hardening_test.go +++ b/internal/sandbox/hardening_test.go @@ -206,3 +206,50 @@ func TestShellHelpReportsSeparateResourceBudgets(t *testing.T) { } } } + +func TestNewCreatesMissingHomeDirectory(t *testing.T) { + t.Run("default home", func(t *testing.T) { + box := testSandbox(t) + if !box.FS.IsDir("/home/operator") { + t.Fatal("New() did not create the default home directory") + } + for _, line := range []string{"cd", "cd ~", "ls ~/"} { + if _, err := box.Execute(line); err != nil { + t.Errorf("Execute(%q) error = %v", line, err) + } + } + if box.CWD != "/home/operator" { + t.Errorf("CWD after cd = %q, want /home/operator", box.CWD) + } + }) + + t.Run("mission home", func(t *testing.T) { + box, err := New(mission.Setup{ + Directories: []mission.DirectorySpec{{Path: "/work"}}, + Environment: map[string]string{"HOME": "/srv/oncall"}, + }, "/work") + if err != nil { + t.Fatal(err) + } + if !box.FS.IsDir("/srv/oncall") || box.FS.Exists("/home/operator") { + t.Fatalf("paths = %v, want only the mission home created", box.FS.Paths()) + } + }) + + t.Run("existing setup is preserved", func(t *testing.T) { + box, err := New(mission.Setup{ + Directories: []mission.DirectorySpec{{Path: "/home/operator", Mode: "700"}}, + Files: []mission.FileSpec{{Path: "/home/operator/notes.txt", Content: "keep\n"}}, + }, "/home/operator") + if err != nil { + t.Fatal(err) + } + entry, _ := box.FS.Entry("/home/operator") + if entry.Mode != 0o700 { + t.Errorf("home mode = %o, want the mission's 700", entry.Mode) + } + if content, err := box.FS.ReadFile("/home/operator/notes.txt"); err != nil || content != "keep\n" { + t.Errorf("notes.txt = %q, %v", content, err) + } + }) +} diff --git a/internal/sandbox/lexer.go b/internal/sandbox/lexer.go index 2f2f76a..26564e6 100644 --- a/internal/sandbox/lexer.go +++ b/internal/sandbox/lexer.go @@ -123,10 +123,19 @@ func lex(line string, env map[string]string) ([]token, error) { quote = 0 continue } + // Inside double quotes a backslash escapes only $, `, ", \, and newline; + // before any other character it stays literal, so "a\nb" reaches + // printf with its \n intact. if quote == '"' && char == '\\' { if i+1 >= len(runes) { return nil, fmt.Errorf("unfinished escape") } + if !strings.ContainsRune("$`\"\\\n", runes[i+1]) { + if err := writeRune(char); err != nil { + return nil, err + } + continue + } i++ if err := writeRune(runes[i]); err != nil { return nil, err diff --git a/internal/sandbox/manuals.go b/internal/sandbox/manuals.go index aca7236..ec42d03 100644 --- a/internal/sandbox/manuals.go +++ b/internal/sandbox/manuals.go @@ -19,6 +19,7 @@ func shellHelp(args []string) (string, error) { commands := CommandNames() return "Available lab commands:\n " + strings.Join(commands, " ") + "\n\nShell features: pipelines (|), input (<), output (>), and append (>>) redirection." + + "\nEnter one command per line; ;, &&, ||, &, 2>, and $(...) are not supported." + fmt.Sprintf("\nSandbox limits: %d KiB per command line; %d KiB expanded tokens; %d expanded arguments; %d pipeline stages; %d command dispatches; %d MiB per file and command output; %d MiB filesystem content and %d MiB archive payload; %d filesystem entries and %d archive entries.", maxCommandLineBytes/1024, maxExpandedTokenBytes/1024, maxExpandedArguments, maxPipelineStages, maxExecutionDispatchSteps, maxVirtualFileBytes/(1024*1024), maxVirtualFileSystemBytes/(1024*1024), maxVirtualArchiveBytes/(1024*1024), maxVirtualEntries, maxVirtualArchiveEntries) + @@ -48,10 +49,10 @@ var commandManuals = map[string]string{ "env": "env — print the current environment", "export": "export NAME=value... — set shell environment variables", "find": "find [PATH] [-name GLOB] [-type f|d] [-exec COMMAND {} \\;]", - "grep": "grep [-rilnvcFwE] PATTERN [FILE...] — print lines matching a pattern", + "grep": "grep [-rilnvcFwE] PATTERN [FILE...] — print lines matching a basic regex; \\| \\+ \\( \\) are operators, or use -E for extended syntax", "gzip": "gzip FILE... — add the .gz suffix to virtual compressed files", "gunzip": "gunzip FILE.gz... — restore virtual compressed files", - "head": "head [-n COUNT] [FILE...] — print the first lines", + "head": "head [-n [-]COUNT] [FILE...] — print the first lines; -n -N prints all but the last N", "help": "help [COMMAND] — list lab commands or show focused command help", "history": "history — show commands entered in this mission attempt", "kill": "kill [-9|-15] PID... — stop a mission process", @@ -60,12 +61,12 @@ var commandManuals = map[string]string{ "man": "man COMMAND — show the same focused help as help COMMAND", "mkdir": "mkdir [-p] DIR... — create directories", "mv": "mv SOURCE... DEST — move or rename paths", - "printf": "printf FORMAT [VALUE...] — print formatted text with %s and escapes", + "printf": "printf FORMAT [VALUE...] — print text with %s, %d, %%, widths such as %-8s, and \\n or \\t; the format repeats for extra values", "ps": "ps — list the mission's running processes", "pwd": "pwd — print the current working directory", - "rm": "rm [-rf] PATH... — remove paths inside the virtual filesystem", + "rm": "rm [-rf] PATH... — remove files; directories need -r (or rmdir when empty)", "rmdir": "rmdir DIR... — remove empty directories", - "sed": "sed [-i] 's/REGEX/REPLACEMENT/g' [FILE] — transform text", + "sed": "sed [-inE] 's/REGEX/REPLACEMENT/[gp]' [FILE...] — substitute text; & is the match, \\1 a \\(group\\), and -E enables extended regex", "sh": "sh FILE — run a virtual UTF-8 script through the OpsQuest teaching shell\n" + "Blank lines, comments, #!/bin/sh, existing commands, variables, pipelines, and redirection are supported.\n" + "Executable paths such as ./deploy.sh require a shebang and an executable mode; sh FILE does not.\n" + @@ -74,16 +75,16 @@ var commandManuals = map[string]string{ "Options, arguments, stdin, loops, conditionals, functions, substitutions, background jobs, and external programs are unsupported.", "sort": "sort [-nru] [FILE...] — sort lines", "stat": "stat PATH... — inspect type, size, owner, and mode", - "tail": "tail [-n COUNT] [FILE...] — print the last lines", + "tail": "tail [-n [+]COUNT] [FILE...] — print the last lines; -n +N starts at line N", "tar": "tar -xf ARCHIVE [-C DIR] — extract; -C is extraction-only; -cf creates and -tf lists", "touch": "touch FILE... — create empty files when they do not exist", - "tr": "tr [-ds] SET1 [SET2] — translate, delete, or squeeze characters", + "tr": "tr [-ds] SET1 [SET2] — translate, delete, or squeeze characters; sets accept ranges such as a-z and classes such as [:upper:]", "uniq": "uniq [-c] [FILE] — collapse adjacent duplicate lines", "vi": "vi FILE — edit one virtual UTF-8 text file up to 256 KiB interactively\n" + "Normal mode: h/j/k/l or arrows move, i inserts, x deletes a character, and dd deletes a line.\n" + "Insert mode: type text, Enter adds a line, Backspace deletes, and Esc returns to Normal mode.\n" + "Commands: :w writes, :q quits an unchanged buffer, :wq writes and quits, and :q! discards changes.\n" + "Options, multiple files, pipelines, redirection, shell escapes, plugins, and other vi features are unsupported.", - "wc": "wc [-l|-w|-c] [FILE...] — count lines, words, or bytes", + "wc": "wc [-lwc] [FILE...] — count lines, words, and bytes (all three by default)", "whoami": "whoami — print the current virtual user", } diff --git a/internal/sandbox/options.go b/internal/sandbox/options.go index f6f4692..8d90e49 100644 --- a/internal/sandbox/options.go +++ b/internal/sandbox/options.go @@ -34,3 +34,21 @@ func parseShortOptions(args []string, valid string, dashIsOperand bool) (string, } return options.String(), operands, nil } + +// operandsOnly returns the operands of a command that accepts no options in +// this lab. As in GNU tools, -- ends option parsing and a lone - remains an +// operand; any other dash-prefixed argument is reported as an unknown option +// instead of being treated as a file name. +func operandsOnly(args []string) ([]string, error) { + operands := make([]string, 0, len(args)) + for index, arg := range args { + if arg == "--" { + return append(operands, args[index+1:]...), nil + } + if strings.HasPrefix(arg, "-") && arg != "-" { + return nil, fmt.Errorf("unknown option %s", arg) + } + operands = append(operands, arg) + } + return operands, nil +} diff --git a/internal/sandbox/regex.go b/internal/sandbox/regex.go new file mode 100644 index 0000000..cace985 --- /dev/null +++ b/internal/sandbox/regex.go @@ -0,0 +1,146 @@ +package sandbox + +import ( + "fmt" + "strconv" + "strings" +) + +// translateBasicRegex converts a POSIX basic regular expression, the default +// syntax of grep and sed, into Go RE2 syntax. In basic syntax ( ) { } | + ? +// are literal and their backslash-escaped forms are operators (GNU's \| \+ \? +// extensions included), so 'ERROR\|WARN' alternates while 'ERROR|WARN' +// matches a literal bar. Extended syntax (-E) is passed to RE2 unchanged. +func translateBasicRegex(pattern string) (string, error) { + runes := []rune(pattern) + var out strings.Builder + // caretAnchors is true where ^ is an anchor: the start of the pattern, a + // group, or an alternative. starLiteral is true where * has nothing to + // repeat and is therefore literal; that also includes just after ^. + caretAnchors, starLiteral := true, true + for index := 0; index < len(runes); index++ { + char := runes[index] + nextCaretAnchors, nextStarLiteral := false, false + switch { + case char == '[': + end := bracketExpressionEnd(runes, index) + if end < 0 { + return "", fmt.Errorf("unterminated bracket expression") + } + // Backslash is an ordinary character inside POSIX brackets. + out.WriteString(strings.ReplaceAll(string(runes[index:end+1]), `\`, `\\`)) + index = end + case char == '\\': + if index+1 >= len(runes) { + return "", fmt.Errorf("trailing backslash") + } + index++ + escaped := runes[index] + switch { + case escaped == '(' || escaped == '|': + out.WriteRune(escaped) + nextCaretAnchors, nextStarLiteral = true, true + case strings.ContainsRune("){}+?", escaped): + out.WriteRune(escaped) + case escaped == '<' || escaped == '>': + out.WriteString(`\b`) + case escaped >= '1' && escaped <= '9': + return "", fmt.Errorf("backreferences such as \\%c are not supported in patterns", escaped) + default: + out.WriteRune('\\') + out.WriteRune(escaped) + } + case strings.ContainsRune("(){}|+?", char): + out.WriteRune('\\') + out.WriteRune(char) + case char == '*' && starLiteral: + out.WriteString(`\*`) + case char == '^' && caretAnchors: + out.WriteRune(char) + nextStarLiteral = true + case char == '^': + out.WriteString(`\^`) + case char == '$' && !basicDollarAnchors(runes, index): + out.WriteString(`\$`) + default: + out.WriteRune(char) + } + caretAnchors, starLiteral = nextCaretAnchors, nextStarLiteral + } + return out.String(), nil +} + +// basicDollarAnchors reports whether $ at index ends the pattern, a group, or +// an alternative; elsewhere a basic regular expression treats it literally. +func basicDollarAnchors(runes []rune, index int) bool { + rest := runes[index+1:] + return len(rest) == 0 || len(rest) >= 2 && rest[0] == '\\' && (rest[1] == ')' || rest[1] == '|') +} + +// bracketExpressionEnd returns the index of the ] closing the bracket +// expression opening at start, or -1. A ] immediately after [ or [^ is a +// member, and [:class:], [.coll.], and [=equiv=] spans are skipped whole. +func bracketExpressionEnd(runes []rune, start int) int { + index := start + 1 + if index < len(runes) && runes[index] == '^' { + index++ + } + if index < len(runes) && runes[index] == ']' { + index++ + } + for ; index < len(runes); index++ { + if runes[index] == ']' { + return index + } + if runes[index] != '[' || index+1 >= len(runes) || !strings.ContainsRune(":.=", runes[index+1]) { + continue + } + delimiter := runes[index+1] + for closing := index + 2; closing+1 < len(runes); closing++ { + if runes[closing] == delimiter && runes[closing+1] == ']' { + index = closing + 1 + break + } + } + } + return -1 +} + +// translateSedReplacement converts sed replacement text into a Go +// regexp.Expand template: & is the whole match, \1-\9 are groups, \n and \t +// are newline and tab, any other escaped character is literal, and a literal +// $ is doubled so Go does not read it as a group reference. +func translateSedReplacement(replacement string, groups int) (string, error) { + var out strings.Builder + for index := 0; index < len(replacement); index++ { + char := replacement[index] + switch { + case char == '&': + out.WriteString("${0}") + case char == '$': + out.WriteString("$$") + case char == '\\' && index+1 < len(replacement): + index++ + escaped := replacement[index] + switch { + case escaped >= '0' && escaped <= '9': + group, _ := strconv.Atoi(string(escaped)) + if group > groups { + return "", fmt.Errorf("invalid reference \\%d on s command's replacement: the pattern has %d group(s)", group, groups) + } + out.WriteString("${" + string(escaped) + "}") + case escaped == 'n': + out.WriteByte('\n') + case escaped == 't': + out.WriteByte('\t') + case escaped == '$': + out.WriteString("$$") + default: + out.WriteByte(escaped) + } + default: + out.WriteByte(char) + } + } + return out.String(), nil +} diff --git a/internal/sandbox/sandbox.go b/internal/sandbox/sandbox.go index 7d162c6..4e91fa2 100644 --- a/internal/sandbox/sandbox.go +++ b/internal/sandbox/sandbox.go @@ -97,6 +97,13 @@ func New(setup mission.Setup, startDir string) (*Sandbox, error) { } box.Archives = archives } + // A real login always has a home directory, so `cd`, `cd ~`, and `ls ~/` + // keep working in missions whose setup does not mention it. + if home := box.Env["HOME"]; path.IsAbs(home) && !box.FS.Exists(path.Clean(home)) { + if err := box.FS.EnsureDir(home, 0o755); err != nil { + return nil, fmt.Errorf("home directory %s: %w", home, err) + } + } if !box.FS.IsDir(box.CWD) { return nil, fmt.Errorf("start directory %s does not exist", box.CWD) } diff --git a/internal/sandbox/shell.go b/internal/sandbox/shell.go index 56b4d6b..2084f7d 100644 --- a/internal/sandbox/shell.go +++ b/internal/sandbox/shell.go @@ -56,6 +56,9 @@ func (s *Sandbox) Execute(line string) (Result, error) { } func (s *Sandbox) executeLine(line string, context *executionContext, allowInteractive bool) (Result, error) { + if err := validateShellSyntax(line); err != nil { + return Result{}, err + } tokens, err := lex(line, s.Env) if err != nil { return Result{}, err diff --git a/internal/sandbox/syntax.go b/internal/sandbox/syntax.go new file mode 100644 index 0000000..4f0910a --- /dev/null +++ b/internal/sandbox/syntax.go @@ -0,0 +1,142 @@ +package sandbox + +import ( + "fmt" + "strings" + "unicode" +) + +// validateShellSyntax rejects shell language outside the teaching subset +// before lexing. Interactive lines and script lines share these rules, so +// syntax such as `a; b`, `a && b`, or `cmd 2>/dev/null` fails with a clear +// message instead of silently becoming literal arguments. +func validateShellSyntax(line string) error { + runes := []rune(line) + var quote rune + escaped := false + tokenStarted := false + tokenStart := 0 + digitsOnly := false + for index, char := range runes { + if escaped { + escaped = false + tokenStarted = true + digitsOnly = false + continue + } + if quote == '\'' { + if char == quote { + quote = 0 + } + continue + } + if char == '\\' { + escaped = true + continue + } + if quote == '"' { + if char == quote { + quote = 0 + continue + } + if char == '`' || char == '$' && index+1 < len(runes) && runes[index+1] == '(' { + return fmt.Errorf("command substitution is not supported") + } + if char == '$' && isUnsupportedShellParameter(runes[index+1:]) { + return unsupportedParameterError(runes[index+1:]) + } + continue + } + + if unicode.IsSpace(char) { + tokenStarted = false + continue + } + if char == '#' && !tokenStarted { + break + } + if char == '\'' || char == '"' { + quote = char + tokenStarted = true + digitsOnly = false + continue + } + next := rune(0) + if index+1 < len(runes) { + next = runes[index+1] + } + switch char { + case ';': + return fmt.Errorf("shell control syntax %q is not supported; enter each command on its own line", ";") + case '&': + if index > 0 && (runes[index-1] == '>' || runes[index-1] == '<') { + return fdRedirectionError(string(runes[index-1 : index+1])) + } + if next == '>' { + return fdRedirectionError("&>") + } + if next == '&' { + return fmt.Errorf("shell control syntax %q is not supported; enter each command on its own line", "&&") + } + return fmt.Errorf("shell control syntax %q is not supported; background jobs are not available", "&") + case '(', ')': + return fmt.Errorf("shell control syntax %q is not supported", string(char)) + case '`': + return fmt.Errorf("command substitution is not supported") + case '|': + if next == '|' { + return fmt.Errorf("control operator || is not supported; enter each command on its own line") + } + tokenStarted = false + continue + case '<', '>': + if tokenStarted && digitsOnly { + return fdRedirectionError(string(runes[tokenStart : index+1])) + } + tokenStarted = false + continue + case '$': + if next == '(' { + return fmt.Errorf("command substitution is not supported") + } + if isUnsupportedShellParameter(runes[index+1:]) { + return unsupportedParameterError(runes[index+1:]) + } + } + isDigit := char >= '0' && char <= '9' + if !tokenStarted { + tokenStart = index + digitsOnly = isDigit + } else { + digitsOnly = digitsOnly && isDigit + } + tokenStarted = true + } + + return nil +} + +func fdRedirectionError(operator string) error { + return fmt.Errorf("file-descriptor redirection such as %q is not supported; > and >> redirect standard output only", operator) +} + +func isUnsupportedShellParameter(input []rune) bool { + if len(input) == 0 { + return false + } + if unicode.IsDigit(input[0]) || strings.ContainsRune("?$!#*@-", input[0]) { + return true + } + return len(input) > 1 && input[0] == '{' && (unicode.IsDigit(input[1]) || strings.ContainsRune("?$!#*@-", input[1])) +} + +func unsupportedParameterError(input []rune) error { + name := string(input[0]) + if input[0] == '{' { + name = string(input) + if end := strings.IndexRune(name, '}'); end >= 0 { + name = name[:end+1] + } + } + return fmt.Errorf("positional and special parameters such as %q are not supported; use single quotes to pass a literal $", "$"+name) +} diff --git a/internal/sandbox/syntax_test.go b/internal/sandbox/syntax_test.go new file mode 100644 index 0000000..2355135 --- /dev/null +++ b/internal/sandbox/syntax_test.go @@ -0,0 +1,100 @@ +package sandbox + +import ( + "strings" + "testing" +) + +func TestInteractiveLinesRejectUnsupportedShellSyntaxBeforeRunning(t *testing.T) { + tests := []struct { + name string + line string + want string + }{ + {name: "semicolon", line: "touch /out/first.txt; touch /out/second.txt", want: `shell control syntax ";"`}, + {name: "and list", line: "touch /out/first.txt && touch /out/second.txt", want: `shell control syntax "&&"`}, + {name: "or list", line: "touch /out/first.txt || touch /out/second.txt", want: "control operator ||"}, + {name: "background job", line: "touch /out/first.txt &", want: "background jobs"}, + {name: "subshell", line: "(touch /out/first.txt)", want: `shell control syntax "("`}, + {name: "stderr redirection", line: "touch /out/first.txt 2>/out/errors.txt", want: `redirection such as "2>"`}, + {name: "stderr append", line: "touch /out/first.txt 2>>/out/errors.txt", want: `redirection such as "2>"`}, + {name: "stderr to stdout", line: "touch /out/first.txt 2>&1", want: `redirection such as "2>"`}, + {name: "stdout to stderr", line: "touch /out/first.txt >&2", want: `redirection such as ">&"`}, + {name: "both streams", line: "touch /out/first.txt &>/out/all.txt", want: `redirection such as "&>"`}, + {name: "numbered input", line: "touch /out/first.txt 0x'`, want: "; && || & () `pwd` $(pwd) $? 2>x\n"}, + {name: "escaped punctuation", line: `echo \; \& \$? 2\>x`, want: "; & $? 2>x\n"}, + {name: "spaced digit argument", line: "echo 2 > /out/digit.txt", want: ""}, + {name: "quoted digit before redirection", line: `echo hi '2'>/out/quoted.txt`, want: ""}, + {name: "option before redirection", line: "head -2>/out/head.txt events.log", want: ""}, + {name: "find exec terminator", line: `find . -name '*.log' -exec grep -l ERROR {} \;`, want: "./events.log\n"}, + {name: "named variables", line: `echo "$TARGET" ${TARGET}`, want: "staging staging\n"}, + {name: "trailing comment", line: "echo ok # ; && $(pwd)", want: "ok\n"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + box := testSandbox(t) + result, err := box.Execute(test.line) + if err != nil { + t.Fatalf("Execute(%q) error = %v", test.line, err) + } + if result.Output != test.want { + t.Errorf("Execute(%q) output = %q, want %q", test.line, result.Output, test.want) + } + }) + } + + t.Run("comment after an operator matches the lexer", func(t *testing.T) { + for _, line := range []string{"cat events.log |# ; && $?", "echo hi >#; $(pwd)"} { + if err := validateShellSyntax(line); err != nil { + t.Errorf("validateShellSyntax(%q) treated a comment as code: %v", line, err) + } + } + }) + + t.Run("digit arguments still redirect stdout", func(t *testing.T) { + box := testSandbox(t) + for _, line := range []string{"echo 2 > /out/digit.txt", `echo hi '2'>/out/quoted.txt`} { + if _, err := box.Execute(line); err != nil { + t.Fatalf("Execute(%q) error = %v", line, err) + } + } + for path, want := range map[string]string{"/out/digit.txt": "2\n", "/out/quoted.txt": "hi 2\n"} { + content, err := box.FS.ReadFile(path) + if err != nil || content != want { + t.Errorf("%s = %q, %v; want %q", path, content, err, want) + } + } + }) +}