moth/pkg/transpile/puzzle.go

411 lines
9.1 KiB
Go
Raw Normal View History

2020-09-08 17:49:02 -06:00
package transpile
import (
"bufio"
"bytes"
2020-08-28 17:41:17 -06:00
"context"
2020-09-04 18:28:23 -06:00
"crypto/sha256"
2020-08-28 17:41:17 -06:00
"encoding/json"
2020-09-11 17:33:43 -06:00
"errors"
"fmt"
"io"
2020-09-01 20:12:57 -06:00
"log"
"net/mail"
2020-08-28 17:41:17 -06:00
"os/exec"
2020-09-11 13:03:19 -06:00
"path"
2020-08-28 17:41:17 -06:00
"strconv"
"strings"
2020-08-28 17:41:17 -06:00
"time"
2020-08-14 20:26:04 -06:00
2020-09-11 20:46:17 -06:00
"github.com/russross/blackfriday/v2"
2020-08-28 17:41:17 -06:00
"github.com/spf13/afero"
2020-08-14 20:26:04 -06:00
"gopkg.in/yaml.v2"
)
2020-09-04 18:28:23 -06:00
// Puzzle contains everything about a puzzle that a client would see.
type Puzzle struct {
Pre struct {
Authors []string
Attachments []string
Scripts []string
Body string
2020-09-11 17:33:43 -06:00
AnswerPattern string
AnswerHashes []string
2020-09-04 18:28:23 -06:00
}
Post struct {
Objective string
Success struct {
Acceptable string
Mastery string
}
KSAs []string
}
Debug struct {
Log []string
Errors []string
Hints []string
Summary string
}
Answers []string
}
func (puzzle *Puzzle) computeAnswerHashes() {
if len(puzzle.Answers) == 0 {
return
}
puzzle.Pre.AnswerHashes = make([]string, len(puzzle.Answers))
for i, answer := range puzzle.Answers {
sum := sha256.Sum256([]byte(answer))
hexsum := fmt.Sprintf("%x", sum)
puzzle.Pre.AnswerHashes[i] = hexsum
}
}
// StaticPuzzle contains everything a static puzzle might tell us.
type StaticPuzzle struct {
Pre struct {
Authors []string
Attachments []StaticAttachment
Scripts []StaticAttachment
AnswerPattern string
}
Post struct {
Objective string
Success struct {
Acceptable string
Mastery string
}
KSAs []string
}
Debug struct {
Log []string
Errors []string
Hints []string
Summary string
}
Answers []string
}
// StaticAttachment carries information about an attached file.
type StaticAttachment struct {
Filename string // Filename presented as part of puzzle
FilesystemPath string // Filename in backing FS (URL, mothball, or local FS)
}
2020-09-08 17:49:02 -06:00
// ReadSeekCloser provides io.Reader, io.Seeker, and io.Closer.
type ReadSeekCloser interface {
io.Reader
io.Seeker
io.Closer
}
2020-09-04 13:00:23 -06:00
// PuzzleProvider establishes the functionality required to provide one puzzle.
type PuzzleProvider interface {
// Puzzle returns a Puzzle struct for the current puzzle.
Puzzle() (Puzzle, error)
// Open returns a newly-opened file.
2020-09-08 17:49:02 -06:00
Open(filename string) (ReadSeekCloser, error)
2020-09-04 13:00:23 -06:00
// Answer returns whether the provided answer is correct.
Answer(answer string) bool
}
2020-09-11 13:03:19 -06:00
// NewFsPuzzle returns a new FsPuzzle.
func NewFsPuzzle(fs afero.Fs) PuzzleProvider {
var command string
if info, err := fs.Stat("mkpuzzle"); (err == nil) && (info.Mode()&0100 != 0) {
// Try to get the actual path to the executable
if pfs, ok := fs.(*RecursiveBasePathFs); ok {
if command, err = pfs.RealPath(info.Name()); err != nil {
log.Println("Unable to resolve full path to", info.Name(), pfs)
}
} else if pfs, ok := fs.(*afero.BasePathFs); ok {
if command, err = pfs.RealPath(info.Name()); err != nil {
log.Println("Unable to resolve full path to", info.Name(), pfs)
2020-09-04 13:00:23 -06:00
}
}
2020-08-28 17:41:17 -06:00
}
2020-09-11 13:03:19 -06:00
if command != "" {
return FsCommandPuzzle{
fs: fs,
command: command,
timeout: 2 * time.Second,
}
}
2020-09-04 13:00:23 -06:00
return FsPuzzle{
2020-09-11 13:03:19 -06:00
fs: fs,
2020-09-04 13:00:23 -06:00
}
2020-09-11 13:03:19 -06:00
}
// NewFsPuzzlePoints returns a new FsPuzzle for points.
func NewFsPuzzlePoints(fs afero.Fs, points int) PuzzleProvider {
return NewFsPuzzle(NewRecursiveBasePathFs(fs, strconv.Itoa(points)))
2019-08-17 13:09:09 -06:00
}
2020-09-03 20:04:43 -06:00
// FsPuzzle is a single puzzle's directory.
type FsPuzzle struct {
fs afero.Fs
2020-09-01 20:12:57 -06:00
mkpuzzle bool
}
2020-09-03 20:04:43 -06:00
// Puzzle returns a Puzzle struct for the current puzzle.
func (fp FsPuzzle) Puzzle() (Puzzle, error) {
2020-09-04 18:28:23 -06:00
var puzzle Puzzle
static, body, err := fp.staticPuzzle()
if err != nil {
return puzzle, err
}
// Convert to an exportable Puzzle
puzzle.Post = static.Post
puzzle.Debug = static.Debug
puzzle.Answers = static.Answers
puzzle.Pre.Authors = static.Pre.Authors
puzzle.Pre.Body = string(body)
puzzle.Pre.AnswerPattern = static.Pre.AnswerPattern
puzzle.Pre.Attachments = make([]string, len(static.Pre.Attachments))
for i, attachment := range static.Pre.Attachments {
puzzle.Pre.Attachments[i] = attachment.Filename
}
puzzle.Pre.Scripts = make([]string, len(static.Pre.Scripts))
for i, script := range static.Pre.Scripts {
puzzle.Pre.Scripts[i] = script.Filename
}
puzzle.computeAnswerHashes()
return puzzle, nil
}
// Open returns a newly-opened file.
2020-09-08 17:49:02 -06:00
func (fp FsPuzzle) Open(name string) (ReadSeekCloser, error) {
empty := nopCloser{new(bytes.Reader)}
2020-09-04 18:28:23 -06:00
static, _, err := fp.staticPuzzle()
if err != nil {
return empty, err
}
var fsPath string
for _, attachment := range append(static.Pre.Attachments, static.Pre.Scripts...) {
if attachment.Filename == name {
if attachment.FilesystemPath == "" {
fsPath = attachment.Filename
} else {
fsPath = attachment.FilesystemPath
}
}
}
if fsPath == "" {
return empty, fmt.Errorf("Not listed in attachments or scripts: %s", name)
}
return fp.fs.Open(fsPath)
}
func (fp FsPuzzle) staticPuzzle() (StaticPuzzle, []byte, error) {
2020-09-03 20:04:43 -06:00
r, err := fp.fs.Open("puzzle.md")
2020-08-28 17:41:17 -06:00
if err != nil {
2020-08-31 16:37:51 -06:00
var err2 error
2020-09-03 20:04:43 -06:00
if r, err2 = fp.fs.Open("puzzle.moth"); err2 != nil {
2020-09-04 18:28:23 -06:00
return StaticPuzzle{}, nil, err
2020-08-31 16:37:51 -06:00
}
2020-08-28 17:41:17 -06:00
}
defer r.Close()
headerBuf := new(bytes.Buffer)
2020-09-01 20:12:57 -06:00
headerParser := rfc822HeaderParser
2020-08-28 17:41:17 -06:00
headerEnd := ""
scanner := bufio.NewScanner(r)
lineNo := 0
for scanner.Scan() {
line := scanner.Text()
2020-08-28 17:41:17 -06:00
lineNo++
if lineNo == 1 {
if line == "---" {
2020-09-01 20:12:57 -06:00
headerParser = yamlHeaderParser
headerEnd = "---"
continue
}
}
if line == headerEnd {
2020-08-28 17:41:17 -06:00
headerBuf.WriteRune('\n')
break
}
headerBuf.WriteString(line)
headerBuf.WriteRune('\n')
}
2019-08-17 13:09:09 -06:00
bodyBuf := new(bytes.Buffer)
for scanner.Scan() {
line := scanner.Text()
2020-08-28 17:41:17 -06:00
lineNo++
bodyBuf.WriteString(line)
bodyBuf.WriteRune('\n')
}
2019-08-17 13:09:09 -06:00
2020-09-04 18:28:23 -06:00
static, err := headerParser(headerBuf)
2020-09-01 20:12:57 -06:00
if err != nil {
2020-09-04 18:28:23 -06:00
return static, nil, err
}
2020-08-14 20:26:04 -06:00
2020-09-04 18:28:23 -06:00
body := blackfriday.Run(bodyBuf.Bytes())
2020-08-28 17:41:17 -06:00
2020-09-04 18:28:23 -06:00
return static, body, err
2020-08-28 17:41:17 -06:00
}
2020-09-04 18:28:23 -06:00
func legacyAttachmentParser(val []string) []StaticAttachment {
ret := make([]StaticAttachment, len(val))
2020-09-01 20:12:57 -06:00
for idx, txt := range val {
parts := strings.SplitN(txt, " ", 3)
2020-09-04 18:28:23 -06:00
cur := StaticAttachment{}
2020-09-01 20:12:57 -06:00
cur.FilesystemPath = parts[0]
if len(parts) > 1 {
cur.Filename = parts[1]
} else {
cur.Filename = cur.FilesystemPath
}
ret[idx] = cur
}
return ret
}
2020-09-04 18:28:23 -06:00
func yamlHeaderParser(r io.Reader) (StaticPuzzle, error) {
p := StaticPuzzle{}
2020-09-01 20:12:57 -06:00
decoder := yaml.NewDecoder(r)
decoder.SetStrict(true)
err := decoder.Decode(&p)
return p, err
}
2020-09-04 18:28:23 -06:00
func rfc822HeaderParser(r io.Reader) (StaticPuzzle, error) {
p := StaticPuzzle{}
2020-09-01 20:12:57 -06:00
m, err := mail.ReadMessage(r)
if err != nil {
return p, fmt.Errorf("Parsing RFC822 headers: %v", err)
}
for key, val := range m.Header {
key = strings.ToLower(key)
switch key {
case "author":
p.Pre.Authors = val
case "pattern":
p.Pre.AnswerPattern = val[0]
case "script":
p.Pre.Scripts = legacyAttachmentParser(val)
case "file":
p.Pre.Attachments = legacyAttachmentParser(val)
case "answer":
p.Answers = val
case "summary":
p.Debug.Summary = val[0]
case "hint":
p.Debug.Hints = val
case "ksa":
p.Post.KSAs = val
default:
return p, fmt.Errorf("Unknown header field: %s", key)
}
}
return p, nil
}
2020-09-03 20:04:43 -06:00
2020-09-04 13:00:23 -06:00
// Answer checks whether the given answer is correct.
2020-09-03 20:04:43 -06:00
func (fp FsPuzzle) Answer(answer string) bool {
2020-09-04 18:28:23 -06:00
p, _, err := fp.staticPuzzle()
if err != nil {
return false
}
for _, ans := range p.Answers {
if ans == answer {
return true
}
}
2020-09-03 20:04:43 -06:00
return false
}
2020-09-04 13:00:23 -06:00
// FsCommandPuzzle provides an FsPuzzle backed by running a command.
2020-09-03 20:04:43 -06:00
type FsCommandPuzzle struct {
2020-09-04 13:00:23 -06:00
fs afero.Fs
command string
timeout time.Duration
2020-09-03 20:04:43 -06:00
}
2020-09-04 13:00:23 -06:00
// Puzzle returns a Puzzle struct for the current puzzle.
2020-09-03 20:04:43 -06:00
func (fp FsCommandPuzzle) Puzzle() (Puzzle, error) {
2020-09-04 13:00:23 -06:00
ctx, cancel := context.WithTimeout(context.Background(), fp.timeout)
2020-09-03 20:04:43 -06:00
defer cancel()
2020-09-04 13:00:23 -06:00
cmd := exec.CommandContext(ctx, fp.command)
2020-09-11 13:03:19 -06:00
cmd.Dir = path.Dir(fp.command)
2020-09-03 20:04:43 -06:00
stdout, err := cmd.Output()
2020-09-11 17:33:43 -06:00
if exiterr, ok := err.(*exec.ExitError); ok {
return Puzzle{}, errors.New(string(exiterr.Stderr))
} else if err != nil {
2020-09-03 20:04:43 -06:00
return Puzzle{}, err
}
jsdec := json.NewDecoder(bytes.NewReader(stdout))
jsdec.DisallowUnknownFields()
puzzle := Puzzle{}
if err := jsdec.Decode(&puzzle); err != nil {
return Puzzle{}, err
}
2020-09-04 18:28:23 -06:00
puzzle.computeAnswerHashes()
2020-09-03 20:04:43 -06:00
return puzzle, nil
}
2020-09-08 17:49:02 -06:00
type nopCloser struct {
io.ReadSeeker
}
func (c nopCloser) Close() error {
return nil
}
2020-09-04 13:00:23 -06:00
// Open returns a newly-opened file.
2020-09-08 17:49:02 -06:00
// BUG(neale): FsCommandPuzzle.Open() reads everything into memory, and will suck for large files.
func (fp FsCommandPuzzle) Open(filename string) (ReadSeekCloser, error) {
2020-09-04 13:00:23 -06:00
ctx, cancel := context.WithTimeout(context.Background(), fp.timeout)
defer cancel()
2020-09-11 17:33:43 -06:00
cmd := exec.CommandContext(ctx, fp.command, "--file", filename)
2020-09-11 13:03:19 -06:00
cmd.Dir = path.Dir(fp.command)
2020-09-04 13:00:23 -06:00
out, err := cmd.Output()
2020-09-08 17:49:02 -06:00
buf := nopCloser{bytes.NewReader(out)}
2020-09-04 13:00:23 -06:00
if err != nil {
2020-09-04 15:29:06 -06:00
return buf, err
2020-09-04 13:00:23 -06:00
}
2020-09-04 15:29:06 -06:00
return buf, nil
2020-09-03 20:04:43 -06:00
}
2020-09-04 13:00:23 -06:00
// Answer checks whether the given answer is correct.
2020-09-03 20:04:43 -06:00
func (fp FsCommandPuzzle) Answer(answer string) bool {
2020-09-04 13:00:23 -06:00
ctx, cancel := context.WithTimeout(context.Background(), fp.timeout)
defer cancel()
2020-09-11 17:33:43 -06:00
cmd := exec.CommandContext(ctx, fp.command, "--answer", answer)
2020-09-11 13:03:19 -06:00
cmd.Dir = path.Dir(fp.command)
2020-09-04 13:00:23 -06:00
out, err := cmd.Output()
if err != nil {
2020-09-04 15:29:06 -06:00
log.Printf("ERROR: checking answer: %s", err)
2020-09-04 13:00:23 -06:00
return false
}
switch strings.TrimSpace(string(out)) {
case "correct":
return true
}
2020-09-03 20:04:43 -06:00
return false
}