diff --git a/cmd/transpile/basepath.go b/cmd/transpile/basepath.go new file mode 100644 index 0000000..4dddc40 --- /dev/null +++ b/cmd/transpile/basepath.go @@ -0,0 +1,64 @@ +package main + +import ( + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/spf13/afero" +) + +// BasePathFs is an overloaded afero.BasePathFs that has a recursive RealPath(). +type BasePathFs struct { + afero.Fs + source afero.Fs + path string +} + +// NewBasePathFs returns a new BasePathFs. +func NewBasePathFs(source afero.Fs, path string) afero.Fs { + return &BasePathFs{ + Fs: afero.NewBasePathFs(source, path), + source: source, + path: path, + } +} + +// RealPath returns the real path to a file, "breaking out" of the BasePathFs. +func (b *BasePathFs) RealPath(name string) (path string, err error) { + if err := validateBasePathName(name); err != nil { + return name, err + } + + bpath := filepath.Clean(b.path) + path = filepath.Clean(filepath.Join(bpath, name)) + + if parentBasePathFs, ok := b.source.(*BasePathFs); ok { + return parentBasePathFs.RealPath(path) + } else if parentBasePathFs, ok := b.source.(*afero.BasePathFs); ok { + return parentBasePathFs.RealPath(path) + } + + if !strings.HasPrefix(path, bpath) { + return name, os.ErrNotExist + } + + return path, nil +} + +func validateBasePathName(name string) error { + if runtime.GOOS != "windows" { + // Not much to do here; + // the virtual file paths all look absolute on *nix. + return nil + } + + // On Windows a common mistake would be to provide an absolute OS path + // We could strip out the base part, but that would not be very portable. + if filepath.IsAbs(name) { + return os.ErrNotExist + } + + return nil +} diff --git a/cmd/transpile/category.go b/cmd/transpile/category.go index 42a214d..d742eea 100644 --- a/cmd/transpile/category.go +++ b/cmd/transpile/category.go @@ -10,7 +10,7 @@ import ( // NewCategory returns a new category for the given path in the given fs. func NewCategory(fs afero.Fs, cat string) Category { return Category{ - Fs: afero.NewBasePathFs(fs, cat), + Fs: NewBasePathFs(fs, cat), } } @@ -41,7 +41,7 @@ func (c Category) Puzzles() ([]int, error) { return puzzles, nil } -// Puzzle returns the Puzzle associated with points. -func (c Category) Puzzle(points int) (*Puzzle, error) { - return NewPuzzle(c.Fs, points) +// PuzzleDir returns the PuzzleDir associated with points. +func (c Category) PuzzleDir(points int) *PuzzleDir { + return NewPuzzleDir(c.Fs, points) } diff --git a/cmd/transpile/main.go b/cmd/transpile/main.go index db2b0df..4aeec1e 100644 --- a/cmd/transpile/main.go +++ b/cmd/transpile/main.go @@ -86,20 +86,21 @@ func (t *T) PrintInventory() error { // Open writes a file to the writer. func (t *T) Open() error { c := t.NewCategory(t.Cat) - p, err := c.Puzzle(t.Points) - if err != nil { - return err - } + pd := c.PuzzleDir(t.Points) switch t.Filename { case "puzzle.json", "": + p, err := pd.Export() + if err != nil { + return err + } jp, err := json.Marshal(p) if err != nil { return err } t.w.Write(jp) default: - f, err := p.Open(t.Filename) + f, err := pd.Open(t.Filename) if err != nil { return err } diff --git a/cmd/transpile/main_test.go b/cmd/transpile/main_test.go index c37d305..25b304a 100644 --- a/cmd/transpile/main_test.go +++ b/cmd/transpile/main_test.go @@ -47,10 +47,11 @@ body afero.WriteFile(fs, "cat0/21/puzzle.md", []byte("Answer: broken\nSpooon\n"), 0644) afero.WriteFile(fs, "cat0/22/puzzle.md", []byte("---\nanswers:\n - pencil\npre:\n body: Spooon\n---\nSpoon?\n"), 0644) afero.WriteFile(fs, "cat1/93/puzzle.md", []byte("Answer: no\n\nbody"), 0644) + afero.WriteFile(fs, "cat1/barney/puzzle.md", testMothYaml, 0644) return fs } -func TestThings(t *testing.T) { +func TestEverything(t *testing.T) { stdout := new(bytes.Buffer) tp := T{ w: stdout, @@ -75,8 +76,8 @@ func TestThings(t *testing.T) { if err := json.Unmarshal(stdout.Bytes(), &p); err != nil { t.Error(err) } - if p.Answers[0] != "YAML answer" { - t.Error("Didn't return the right object") + if (len(p.Answers) != 1) || (p.Answers[0] != "YAML answer") { + t.Error("Didn't return the right object", p) } stdout.Reset() diff --git a/cmd/transpile/puzzle.go b/cmd/transpile/puzzle.go index 44271bc..2202e4c 100644 --- a/cmd/transpile/puzzle.go +++ b/cmd/transpile/puzzle.go @@ -7,7 +7,9 @@ import ( "encoding/json" "fmt" "io" + "log" "net/mail" + "os" "os/exec" "strconv" "strings" @@ -18,18 +20,159 @@ import ( "gopkg.in/yaml.v2" ) -// NewPuzzle returns a new Puzzle for points. -func NewPuzzle(fs afero.Fs, points int) (*Puzzle, error) { - p := &Puzzle{ - fs: afero.NewBasePathFs(fs, strconv.Itoa(points)), - } - - if err := p.parseMoth(); err != nil { - return p, err +// NewPuzzleDir returns a new PuzzleDir for points. +func NewPuzzleDir(fs afero.Fs, points int) *PuzzleDir { + pd := &PuzzleDir{ + fs: NewBasePathFs(fs, strconv.Itoa(points)), } // BUG(neale): Doesn't yet handle "puzzle.py" or "mkpuzzle" - return p, nil + return pd +} + +// PuzzleDir is a single puzzle's directory. +type PuzzleDir struct { + fs afero.Fs + mkpuzzle bool +} + +// Open returns a newly-opened file. +func (pd *PuzzleDir) Open(name string) (io.ReadCloser, error) { + // BUG(neale): You cannot open generated files in puzzles, only files actually on the disk + if _, err := pd.fs.Stat("" + return pd.fs.Open(name) +} + +// Export returns a Puzzle struct for the current puzzle. +func (pd *PuzzleDir) Export() (Puzzle, error) { + p, staticErr := pd.exportStatic() + if staticErr == nil { + return p, nil + } + + // Only fall through if the static files don't exist. Otherwise, report the error. + if !os.IsNotExist(staticErr) { + return p, staticErr + } + + if p, cmdErr := pd.exportCommand(); cmdErr == nil { + return p, nil + } else if os.IsNotExist(cmdErr) { + // If the command doesn't exist either, report the non-existence of the static file instead. + return p, staticErr + } else { + return p, cmdErr + } +} + +func (pd *PuzzleDir) exportStatic() (Puzzle, error) { + r, err := pd.fs.Open("puzzle.md") + if err != nil { + var err2 error + if r, err2 = pd.fs.Open("puzzle.moth"); err2 != nil { + return Puzzle{}, err + } + } + defer r.Close() + + headerBuf := new(bytes.Buffer) + headerParser := rfc822HeaderParser + headerEnd := "" + + scanner := bufio.NewScanner(r) + lineNo := 0 + for scanner.Scan() { + line := scanner.Text() + lineNo++ + if lineNo == 1 { + if line == "---" { + headerParser = yamlHeaderParser + headerEnd = "---" + continue + } + } + if line == headerEnd { + headerBuf.WriteRune('\n') + break + } + headerBuf.WriteString(line) + headerBuf.WriteRune('\n') + } + + bodyBuf := new(bytes.Buffer) + for scanner.Scan() { + line := scanner.Text() + lineNo++ + bodyBuf.WriteString(line) + bodyBuf.WriteRune('\n') + } + + puzzle, err := headerParser(headerBuf) + if err != nil { + return puzzle, err + } + + // Markdownify the body + if puzzle.Pre.Body != "" { + if bodyBuf.Len() > 0 { + return puzzle, fmt.Errorf("Puzzle body present in header and in moth body") + } + } else { + puzzle.Pre.Body = string(blackfriday.Run(bodyBuf.Bytes())) + } + + return puzzle, nil +} + +func (pd *PuzzleDir) exportCommand() (Puzzle, error) { + bfs, ok := pd.fs.(*BasePathFs) + if !ok { + return Puzzle{}, fmt.Errorf("Fs won't resolve real paths for %v", pd) + } + mkpuzzlePath, err := bfs.RealPath("mkpuzzle") + if err != nil { + return Puzzle{}, err + } + log.Print(mkpuzzlePath) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, mkpuzzlePath) + stdout, err := cmd.Output() + if err != nil { + return Puzzle{}, err + } + + jsdec := json.NewDecoder(bytes.NewReader(stdout)) + jsdec.DisallowUnknownFields() + puzzle := Puzzle{} + if err := jsdec.Decode(&puzzle); err != nil { + return Puzzle{}, err + } + + return puzzle, nil +} + +func legacyAttachmentParser(val []string) []Attachment { + ret := make([]Attachment, len(val)) + for idx, txt := range val { + parts := strings.SplitN(txt, " ", 3) + cur := Attachment{} + cur.FilesystemPath = parts[0] + if len(parts) > 1 { + cur.Filename = parts[1] + } else { + cur.Filename = cur.FilesystemPath + } + if (len(parts) > 2) && (parts[2] == "hidden") { + cur.Listed = false + } else { + cur.Listed = true + } + ret[idx] = cur + } + return ret } // Puzzle contains everything about a puzzle. @@ -56,7 +199,6 @@ type Puzzle struct { Summary string } Answers []string - fs afero.Fs } // Attachment carries information about an attached file. @@ -66,37 +208,19 @@ type Attachment struct { Listed bool // Whether this file is listed as an attachment } -func legacyAttachmentParser(val []string) []Attachment { - ret := make([]Attachment, len(val)) - for idx, txt := range val { - parts := strings.SplitN(txt, " ", 3) - cur := Attachment{} - cur.FilesystemPath = parts[0] - if len(parts) > 1 { - cur.Filename = parts[1] - } else { - cur.Filename = cur.FilesystemPath - } - if (len(parts) > 2) && (parts[2] == "hidden") { - cur.Listed = false - } else { - cur.Listed = true - } - ret[idx] = cur - } - return ret -} - -func (p *Puzzle) yamlHeaderParser(r io.Reader) error { +func yamlHeaderParser(r io.Reader) (Puzzle, error) { + p := Puzzle{} decoder := yaml.NewDecoder(r) decoder.SetStrict(true) - return decoder.Decode(p) + err := decoder.Decode(&p) + return p, err } -func (p *Puzzle) rfc822HeaderParser(r io.Reader) error { +func rfc822HeaderParser(r io.Reader) (Puzzle, error) { + p := Puzzle{} m, err := mail.ReadMessage(r) if err != nil { - return fmt.Errorf("Parsing RFC822 headers: %v", err) + return p, fmt.Errorf("Parsing RFC822 headers: %v", err) } for key, val := range m.Header { @@ -119,99 +243,9 @@ func (p *Puzzle) rfc822HeaderParser(r io.Reader) error { case "ksa": p.Post.KSAs = val default: - return fmt.Errorf("Unknown header field: %s", key) + return p, fmt.Errorf("Unknown header field: %s", key) } } - return nil -} - -func (p *Puzzle) parseMoth() error { - r, err := p.fs.Open("puzzle.md") - if err != nil { - var err2 error - if r, err2 = p.fs.Open("puzzle.moth"); err2 != nil { - return err - } - } - defer r.Close() - - headerBuf := new(bytes.Buffer) - headerParser := p.rfc822HeaderParser - headerEnd := "" - - scanner := bufio.NewScanner(r) - lineNo := 0 - for scanner.Scan() { - line := scanner.Text() - lineNo++ - if lineNo == 1 { - if line == "---" { - headerParser = p.yamlHeaderParser - headerEnd = "---" - continue - } - } - if line == headerEnd { - headerBuf.WriteRune('\n') - break - } - headerBuf.WriteString(line) - headerBuf.WriteRune('\n') - } - - bodyBuf := new(bytes.Buffer) - for scanner.Scan() { - line := scanner.Text() - lineNo++ - bodyBuf.WriteString(line) - bodyBuf.WriteRune('\n') - } - - if err := headerParser(headerBuf); err != nil { - return err - } - - // Markdownify the body - if (p.Pre.Body != "") && (bodyBuf.Len() > 0) { - return fmt.Errorf("Puzzle body present in header and in moth body") - } - p.Pre.Body = string(blackfriday.Run(bodyBuf.Bytes())) - - return nil -} - -func (p *Puzzle) mkpuzzle() error { - bfs, ok := p.fs.(*afero.BasePathFs) - if !ok { - return fmt.Errorf("Fs won't resolve real paths for %v", p) - } - mkpuzzlePath, err := bfs.RealPath("mkpuzzle") - if err != nil { - return err - } - - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - - cmd := exec.CommandContext(ctx, mkpuzzlePath) - stdout, err := cmd.Output() - if err != nil { - return err - } - - jsdec := json.NewDecoder(bytes.NewReader(stdout)) - jsdec.DisallowUnknownFields() - puzzle := new(Puzzle) - if err := jsdec.Decode(puzzle); err != nil { - return err - } - - return nil -} - -// Open returns a newly-opened file. -func (p *Puzzle) Open(name string) (io.ReadCloser, error) { - // BUG(neale): You cannot open generated files in puzzles, only files actually on the disk - return p.fs.Open(name) + return p, nil } diff --git a/cmd/transpile/puzzle_test.go b/cmd/transpile/puzzle_test.go index bbd5435..3f1788f 100644 --- a/cmd/transpile/puzzle_test.go +++ b/cmd/transpile/puzzle_test.go @@ -1,6 +1,8 @@ package main import ( + "bytes" + "io" "strings" "testing" @@ -12,11 +14,12 @@ func TestPuzzle(t *testing.T) { catFs := afero.NewBasePathFs(puzzleFs, "cat0") { - p, err := NewPuzzle(catFs, 1) + pd := NewPuzzleDir(catFs, 1) + p, err := pd.Export() if err != nil { t.Error(err) } - t.Log(p) + if (len(p.Answers) == 0) || (p.Answers[0] != "YAML answer") { t.Error("Answers are wrong", p.Answers) } @@ -29,7 +32,7 @@ func TestPuzzle(t *testing.T) { } { - p, err := NewPuzzle(catFs, 2) + p, err := NewPuzzleDir(catFs, 2).Export() if err != nil { t.Error(err) } @@ -44,26 +47,27 @@ func TestPuzzle(t *testing.T) { } } - if _, err := NewPuzzle(catFs, 3); err != nil { + if _, err := NewPuzzleDir(catFs, 3).Export(); err != nil { t.Error("Legacy `puzzle.moth` file:", err) } - if _, err := NewPuzzle(catFs, 99); err == nil { + if _, err := NewPuzzleDir(catFs, 99).Export(); err == nil { t.Error("Non-existent puzzle", err) } - if _, err := NewPuzzle(catFs, 10); err == nil { + if _, err := NewPuzzleDir(catFs, 10).Export(); err == nil { t.Error("Broken YAML") } - if _, err := NewPuzzle(catFs, 20); err == nil { + if _, err := NewPuzzleDir(catFs, 20).Export(); err == nil { t.Error("Bad RFC822 header") } - if _, err := NewPuzzle(catFs, 21); err == nil { + if _, err := NewPuzzleDir(catFs, 21).Export(); err == nil { t.Error("Boken RFC822 header") } - if _, err := NewPuzzle(catFs, 22); err == nil { + if p, err := NewPuzzleDir(catFs, 22).Export(); err == nil { t.Error("Duplicate bodies") } else if !strings.HasPrefix(err.Error(), "Puzzle body present") { + t.Log(p) t.Error("Wrong error for duplicate body:", err) } } @@ -71,11 +75,29 @@ func TestPuzzle(t *testing.T) { func TestFsPuzzle(t *testing.T) { catFs := afero.NewBasePathFs(afero.NewOsFs(), "testdata") - if _, err := NewPuzzle(catFs, 1); err != nil { + if _, err := NewPuzzleDir(catFs, 1).Export(); err != nil { t.Error(err) } - if _, err := NewPuzzle(catFs, 2); err != nil { + if _, err := NewPuzzleDir(catFs, 2).Export(); err != nil { t.Error(err) } + + mkpuzzleDir := NewPuzzleDir(catFs, 3) + if _, err := mkpuzzleDir.Export(); err != nil { + t.Error(err) + } + + if body, err := mkpuzzleDir.Open("moo.txt"); err != nil { + t.Error(err) + } else { + defer body.Close() + buf := new(bytes.Buffer) + if _, err := io.Copy(buf, body); err != nil { + t.Error(err) + } + if buf.String() != "Moo.\n" { + t.Error("Wrong body") + } + } } diff --git a/cmd/transpile/testdata/3/mkpuzzle b/cmd/transpile/testdata/3/mkpuzzle index 9b73f3c..d7d868b 100755 --- a/cmd/transpile/testdata/3/mkpuzzle +++ b/cmd/transpile/testdata/3/mkpuzzle @@ -6,7 +6,7 @@ case $1 in { "Answers": ["answer"], "Pre": { - "Authors": "neale", + "Authors": ["neale"], "Body": "I am a generated puzzle." } }