Neale Pickett
·
2024-01-03
puzzle.go
1package transpile
2
3import (
4 "bufio"
5 "bytes"
6 "context"
7 "crypto/sha1"
8 "encoding/json"
9 "errors"
10 "fmt"
11 "io"
12 "log"
13 "net/mail"
14 "os"
15 "os/exec"
16 "path"
17 "strconv"
18 "strings"
19 "time"
20
21 "github.com/spf13/afero"
22 "gopkg.in/yaml.v2"
23)
24
25// AnswerResponse is handed back when we ask for an answer to be checked.
26type AnswerResponse struct {
27 Correct bool
28}
29
30// PuzzleDebug is the full suite of debug fields in a puzzle
31
32type PuzzleDebug struct {
33 Log []string
34 Errors []string
35 Hints []string
36 Notes string
37 Summary string
38}
39
40// Puzzle contains everything about a puzzle that a client will see.
41type Puzzle struct {
42 // Debug contains debugging information, omitted in mothballs
43 Debug PuzzleDebug
44
45 // Authors names all authors of this puzzle
46 Authors []string
47
48 // Attachments is a list of filenames used by this puzzle
49 Attachments []string
50
51 // Scripts is a list of EMCAScript files needed by the client for this puzzle
52 Scripts []string
53
54 // Body is the HTML rendering of this puzzle
55 Body string
56
57 // AnswerPattern contains the pattern (regular expression?) used to match valid answers
58 AnswerPattern string
59
60 // AnswerHashes contains hashes of all answers for this puzzle
61 AnswerHashes []string
62
63 // Answers lists all acceptable answers, omitted in mothballs
64 Answers []string
65
66 // Extra is send unchanged to the client.
67 // Eventually, Objective, KSAs, and Success will move into Extra.
68 Extra map[string]any
69
70 // Objective is the learning objective for this puzzle
71 Objective string
72
73 // KSAs lists all KSAs achieved upon successfull completion of this puzzle
74 KSAs []string
75
76 // Success lists the criteria for successfully understanding this puzzle
77 Success struct {
78 // Acceptable describes the minimum work required to be considered successfully understanding this puzzle's concepts
79 Acceptable string
80
81 // Mastery describes the work required to be considered mastering this puzzle's conceptss
82 Mastery string
83 }
84}
85
86func (puzzle *Puzzle) computeAnswerHashes() {
87 if len(puzzle.Answers) == 0 {
88 return
89 }
90 puzzle.AnswerHashes = make([]string, len(puzzle.Answers))
91 for i, answer := range puzzle.Answers {
92 sum := sha1.Sum([]byte(answer))
93 hexsum := fmt.Sprintf("%x", sum)
94 puzzle.AnswerHashes[i] = hexsum[:4]
95 }
96}
97
98// StaticPuzzle contains everything a static puzzle might tell us.
99type StaticPuzzle struct {
100 Authors []string
101 Attachments []StaticAttachment
102 Scripts []StaticAttachment
103 AnswerPattern string
104 Answers []string
105 Debug PuzzleDebug
106 Extra map[string]any
107 Objective string
108 Success struct {
109 Acceptable string
110 Mastery string
111 }
112 KSAs []string
113}
114
115// StaticAttachment carries information about an attached file.
116type StaticAttachment struct {
117 Filename string // Filename presented as part of puzzle
118 FilesystemPath string // Filename in backing FS (URL, mothball, or local FS)
119}
120
121// UnmarshalYAML allows a StaticAttachment to be specified as a single string.
122// The way the yaml library works is weird.
123func (sa *StaticAttachment) UnmarshalYAML(unmarshal func(interface{}) error) error {
124 if err := unmarshal(&sa.Filename); err == nil {
125 sa.FilesystemPath = sa.Filename
126 return nil
127 }
128
129 parts := new(struct {
130 Filename string
131 FilesystemPath string
132 })
133 if err := unmarshal(parts); err != nil {
134 return err
135 }
136 sa.Filename = parts.Filename
137 sa.FilesystemPath = parts.FilesystemPath
138 return nil
139}
140
141// ReadSeekCloser provides io.Reader, io.Seeker, and io.Closer.
142type ReadSeekCloser interface {
143 io.Reader
144 io.Seeker
145 io.Closer
146}
147
148// PuzzleProvider establishes the functionality required to provide one puzzle.
149type PuzzleProvider interface {
150 // Puzzle returns a Puzzle struct for the current puzzle.
151 Puzzle() (Puzzle, error)
152
153 // Open returns a newly-opened file.
154 Open(filename string) (ReadSeekCloser, error)
155
156 // Answer returns whether the provided answer is correct.
157 Answer(answer string) bool
158}
159
160// NewFsPuzzle returns a new FsPuzzle.
161func NewFsPuzzle(fs afero.Fs) PuzzleProvider {
162 var command string
163
164 bfs := NewRecursiveBasePathFs(fs, "")
165 if info, err := bfs.Stat("mkpuzzle"); !os.IsNotExist(err) {
166 if (info.Mode() & 0100) != 0 {
167 if command, err = bfs.RealPath(info.Name()); err != nil {
168 log.Println("WARN: Unable to resolve full path to", info.Name())
169 }
170 } else {
171 log.Println("WARN: mkpuzzle exists, but isn't executable.")
172 }
173 }
174
175 if command != "" {
176 return FsCommandPuzzle{
177 fs: fs,
178 command: command,
179 timeout: 2 * time.Second,
180 }
181 }
182
183 return FsPuzzle{
184 fs: fs,
185 }
186
187}
188
189// NewFsPuzzlePoints returns a new FsPuzzle for points.
190func NewFsPuzzlePoints(fs afero.Fs, points int) PuzzleProvider {
191 return NewFsPuzzle(NewRecursiveBasePathFs(fs, strconv.Itoa(points)))
192}
193
194// FsPuzzle is a single puzzle's directory.
195type FsPuzzle struct {
196 fs afero.Fs
197 mkpuzzle bool
198}
199
200// Puzzle returns a Puzzle struct for the current puzzle.
201func (fp FsPuzzle) Puzzle() (Puzzle, error) {
202 puzzle := Puzzle{}
203
204 static, body, err := fp.staticPuzzle()
205 if err != nil {
206 return puzzle, err
207 }
208
209 // Convert to an exportable Puzzle
210 puzzle.Debug = static.Debug
211 puzzle.Answers = static.Answers
212 puzzle.Authors = static.Authors
213 puzzle.Extra = static.Extra
214 puzzle.Objective = static.Objective
215 puzzle.KSAs = static.KSAs
216 puzzle.Success = static.Success
217 puzzle.Body = string(body)
218 puzzle.AnswerPattern = static.AnswerPattern
219 puzzle.Attachments = make([]string, len(static.Attachments))
220 for i, attachment := range static.Attachments {
221 puzzle.Attachments[i] = attachment.Filename
222 }
223 puzzle.Scripts = make([]string, len(static.Scripts))
224 for i, script := range static.Scripts {
225 puzzle.Scripts[i] = script.Filename
226 }
227 puzzle.computeAnswerHashes()
228
229 return puzzle, nil
230}
231
232// Open returns a newly-opened file.
233func (fp FsPuzzle) Open(name string) (ReadSeekCloser, error) {
234 empty := nopCloser{new(bytes.Reader)}
235 static, _, err := fp.staticPuzzle()
236 if err != nil {
237 return empty, err
238 }
239
240 var fsPath string
241 for _, attachment := range append(static.Attachments, static.Scripts...) {
242 if attachment.Filename == name {
243 if attachment.FilesystemPath == "" {
244 fsPath = attachment.Filename
245 } else {
246 fsPath = attachment.FilesystemPath
247 }
248 }
249 }
250 if fsPath == "" {
251 return empty, fmt.Errorf("not listed in attachments or scripts: %s", name)
252 }
253
254 return fp.fs.Open(fsPath)
255}
256
257func (fp FsPuzzle) staticPuzzle() (StaticPuzzle, []byte, error) {
258 r, err := fp.fs.Open("puzzle.md")
259 if err != nil {
260 var err2 error
261 if r, err2 = fp.fs.Open("puzzle.moth"); err2 != nil {
262 return StaticPuzzle{}, nil, err
263 }
264 }
265 defer r.Close()
266
267 headerBuf := new(bytes.Buffer)
268 headerParser := rfc822HeaderParser
269 headerEnd := ""
270
271 scanner := bufio.NewScanner(r)
272 lineNo := 0
273 for scanner.Scan() {
274 line := scanner.Text()
275 lineNo++
276 if lineNo == 1 {
277 if line == "---" {
278 headerParser = yamlHeaderParser
279 headerEnd = "---"
280 continue
281 }
282 }
283 if line == headerEnd {
284 headerBuf.WriteRune('\n')
285 break
286 }
287 headerBuf.WriteString(line)
288 headerBuf.WriteRune('\n')
289 }
290
291 bodyBuf := new(bytes.Buffer)
292 for scanner.Scan() {
293 line := scanner.Text()
294 lineNo++
295 bodyBuf.WriteString(line)
296 bodyBuf.WriteRune('\n')
297 }
298
299 static, err := headerParser(headerBuf)
300 if err != nil {
301 return static, nil, err
302 }
303
304 html := new(bytes.Buffer)
305 err = Markdown(bodyBuf, html)
306 return static, html.Bytes(), err
307}
308
309func legacyAttachmentParser(val []string) []StaticAttachment {
310 ret := make([]StaticAttachment, len(val))
311 for idx, txt := range val {
312 parts := strings.SplitN(txt, " ", 3)
313 cur := StaticAttachment{}
314 cur.FilesystemPath = parts[0]
315 if len(parts) > 1 {
316 cur.Filename = parts[1]
317 } else {
318 cur.Filename = cur.FilesystemPath
319 }
320 ret[idx] = cur
321 }
322 return ret
323}
324
325func yamlHeaderParser(r io.Reader) (StaticPuzzle, error) {
326 p := StaticPuzzle{}
327 decoder := yaml.NewDecoder(r)
328 decoder.SetStrict(true)
329 err := decoder.Decode(&p)
330 return p, err
331}
332
333func rfc822HeaderParser(r io.Reader) (StaticPuzzle, error) {
334 p := StaticPuzzle{}
335 m, err := mail.ReadMessage(r)
336 if err != nil {
337 return p, fmt.Errorf("parsing RFC822 headers: %v", err)
338 }
339
340 for key, val := range m.Header {
341 key = strings.ToLower(key)
342 switch key {
343 case "author":
344 p.Authors = val
345 case "pattern":
346 p.AnswerPattern = val[0]
347 case "script":
348 p.Scripts = legacyAttachmentParser(val)
349 case "file":
350 p.Attachments = legacyAttachmentParser(val)
351 case "answer":
352 p.Answers = val
353 case "summary":
354 p.Debug.Summary = val[0]
355 case "hint":
356 p.Debug.Hints = val
357 case "solution":
358 p.Debug.Hints = val
359 case "ksa":
360 p.KSAs = val
361 case "objective":
362 p.Objective = val[0]
363 case "success.acceptable":
364 p.Success.Acceptable = val[0]
365 case "success.mastery":
366 p.Success.Mastery = val[0]
367 default:
368 return p, fmt.Errorf("unknown header field: %s", key)
369 }
370 }
371
372 return p, nil
373}
374
375// Answer checks whether the given answer is correct.
376func (fp FsPuzzle) Answer(answer string) bool {
377 p, _, err := fp.staticPuzzle()
378 if err != nil {
379 return false
380 }
381 for _, ans := range p.Answers {
382 if ans == answer {
383 return true
384 }
385 }
386 return false
387}
388
389// FsCommandPuzzle provides an FsPuzzle backed by running a command.
390type FsCommandPuzzle struct {
391 fs afero.Fs
392 command string
393 timeout time.Duration
394}
395
396func (fp FsCommandPuzzle) run(command string, args ...string) ([]byte, error) {
397 ctx, cancel := context.WithTimeout(context.Background(), fp.timeout)
398 defer cancel()
399
400 cmdargs := append([]string{command}, args...)
401 cmd := exec.CommandContext(ctx, "./"+path.Base(fp.command), cmdargs...)
402 cmd.Dir = path.Dir(fp.command)
403 out, err := cmd.Output()
404 if err, ok := err.(*exec.ExitError); ok {
405 stderr := strings.TrimSpace(string(err.Stderr))
406 return nil, fmt.Errorf("%s (%s)", stderr, err.String())
407 }
408 return out, err
409}
410
411// Puzzle returns a Puzzle struct for the current puzzle.
412func (fp FsCommandPuzzle) Puzzle() (Puzzle, error) {
413 stdout, err := fp.run("puzzle")
414 if exiterr, ok := err.(*exec.ExitError); ok {
415 return Puzzle{}, errors.New(string(exiterr.Stderr))
416 } else if err != nil {
417 return Puzzle{}, err
418 }
419
420 jsdec := json.NewDecoder(bytes.NewReader(stdout))
421 jsdec.DisallowUnknownFields()
422 puzzle := Puzzle{}
423 if err := jsdec.Decode(&puzzle); err != nil {
424 return Puzzle{}, err
425 }
426
427 puzzle.computeAnswerHashes()
428
429 return puzzle, nil
430}
431
432type nopCloser struct {
433 io.ReadSeeker
434}
435
436func (c nopCloser) Close() error {
437 return nil
438}
439
440// Open returns a newly-opened file.
441// BUG(neale): FsCommandPuzzle.Open() reads everything into memory, and will suck for large files.
442func (fp FsCommandPuzzle) Open(filename string) (ReadSeekCloser, error) {
443 stdout, err := fp.run("file", filename)
444 buf := nopCloser{bytes.NewReader(stdout)}
445 if err != nil {
446 return buf, err
447 }
448
449 return buf, nil
450}
451
452// Answer checks whether the given answer is correct.
453func (fp FsCommandPuzzle) Answer(answer string) bool {
454 stdout, err := fp.run("answer", answer)
455 if err != nil {
456 log.Printf("ERROR: checking answer: %s", err)
457 return false
458 }
459
460 ans := AnswerResponse{}
461 if err := json.Unmarshal(stdout, &ans); err != nil {
462 log.Printf("ERROR: checking answer: %s", err)
463 return false
464 }
465
466 return ans.Correct
467}