8.9 KiB
status, phase, depth, files_reviewed, files_reviewed_list, findings, reviewed
| status | phase | depth | files_reviewed | files_reviewed_list | findings | reviewed | ||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| issues | 04-cli-scaffolding-i18n-and-mail | standard | 36 |
|
|
2026-09-18T12:15:00Z |
Phase 4: Code Review Report
Reviewed: 2026-09-18T12:15:00Z Depth: standard Files Reviewed: 36 Status: issues
Summary
Scaffolding, phrasebook, and postcard are a real boot path: make:* writes Winter-shaped leaves and a temp+rename registry.gen.go, summer build runs a models-leaf import check before go build, party.Activate publishes a translator and mailer then fails Boot on missing mail files, and Send renders Markdown through html/template + goldmark (unsafe HTML off) with CR/LF header rejection and mandatory TLS unless none is set. String Vars cannot drop raw <script> into HTML; mail dotted names reject .. / / / \; lang paths must be exactly lang/<locale>/<group>.yaml; SMTP dial-fail tests do not echo secretpass.
The defects that matter are enforcement holes, not missing features. D-11 is prefix-matching and non-recursive, and it silently skips plugins whose replace path is outside the app root. Concurrent make:* can drop registry entries. Locale catalog keys keep the directory spelling (pt_BR vs pt-BR). Mail still executes caller Vars as html/template data, so a template.HTML value bypasses the escaping the tests cover, and the regex post-check does not catch onclick= or <img onerror>.
No path-traversal read of host files was found on the Send or Get paths (lookups are in-memory after boot). scripts/check-phase4.sh does not leak a background process.
Warnings
WR-01: Models leaf check is bypassable
File: internal/build/leaf.go:22-80
Issue: D-11 requires summer build to fail when models/ imports a sibling package. inspectModelsImports only reads immediate models/*.go (subdirectories are skipped), only compares the import string to module/{classes,controllers,console,jobs,middleware,updates}, and checkModelsLeaf continues when localModuleDir is outside the app root. Three bypasses:
models/internal/hook.go(or any nested package) importingmodule/classesis never parsed.import "alias.example/classes"plusreplace alias.example/classes => ./classesdoes not match the module-prefix test.- A plugin whose
go.modreplace is an absolute path outsideappDiris not checked at all.
The happy-path test writes models/bad.go under a plugin inside the copied hello app, so none of these are covered.
Fix: Walk models/ recursively for .go files; resolve each import against the plugin directory (or reject any import whose cleaned replace target is a sibling leaf); return an error instead of continue when a manifest plugin cannot be located inside the app root.
if !underRoot(appDir, pluginDir) {
return fmt.Errorf("build: plugin %s: models leaf check skipped; %s is outside the app root", p.ID, pluginDir)
}
WR-02: Concurrent make:* can lose registry entries
File: internal/build/registry.go:80-111, internal/build/artifact.go:467-477,510-538, internal/build/scaffold.go:249-256
Issue: writeRegistry is atomic for one writer (temp file + Rename). The surrounding protocol is not. rejectDuplicateFile / nextMigrationFile are stat-then-write; refreshRegistry scans then writes with no file lock. Two overlapping make:model / make:migration calls can both pass the existence check, both write artifacts, then the earlier scan can rename a registry that omits the later file. go mod tidy on the same go.mod races the same way. Agent-driven parallel make:* is in scope for this repo's loop.
Fix: Hold an exclusive flock on registry.gen.go (or a make.lock in the plugin dir) from duplicate checks through refreshRegistry + tidy. Open artifact files with O_CREATE|O_EXCL. After the lock, rescan immediately before writing the registry.
WR-03: Locale catalog keys are not canonicalized
File: phrasebook/loader.go:243-263, phrasebook/translator.go:189-214
Issue: Load stores messages under the directory name (pt_BR, en-us). fallbackChain adds the requested string, then language.Parse(requested).String() (typically pt-BR / en-US) and parents, then the configured fallback. D-04's pl-PL → pl case works because files live under pl. A Winter/Laravel lang/pt_BR/ tree is not found for a pt-BR request (and the reverse misses if files use hyphens and the request uses underscores). Map lookup is also case-sensitive (en-us vs en-US). Missing keys then return the raw key instead of the translation that was loaded.
The hyphen parent walk itself is correct; the catalog key is what never matches.
Fix: On Load, key the catalog with a canonical form (strings.ReplaceAll(locale, "_", "-") then language.Parse / tag.String(), plus the original spelling). Use the same function for GetIn / ChoiceIn so request, parent, and file locale collapse to one chain.
WR-04: Caller Vars can bypass HTML escaping; validateHTML is not a real sanitizer
File: postcard/templates.go:336-346,367-377,399-409, postcard/mailer.go:16-23
Issue: Plan T-04-08 / D-07: goldmark stays safe-mode and trusted template.HTML is only for layout-injected, already-rendered content. render passes msg.Vars (map[string]any) straight into execHTML for subject and Markdown body. A plugin that puts template.HTML, template.URL, or template.JS in Vars skips escaping. Layout then injects the Markdown HTML as template.HTML.
The regex net does not close that hole: rawUnsafeTag ignores <img> / <svg>; eventHandler requires whitespace around = so onclick=alert(1) and <svg/onload=…> do not match. String Vars are covered by tests; typed HTML is not. Collection invitations and other user-authored fields will flow through this API.
Fix: Coerce Vars to strings (or reject html/template typed values) before execHTML. Keep template.HTML / template.CSS only on the layout data built inside render. Replace the regex with a real HTML policy (or drop the claim that validateHTML is the safety net).
Info
IN-01: SMTP driver errors are wrapped without redaction
File: postcard/drivers.go:163-165, postcard/mailer.go:79-81
Issue: DialAndSendWithContext is returned as fmt.Errorf("smtp: %w", err) and then postcard: %w. Dial-to-port-1 tests assert secretpass is absent. AUTH / server-response failures are not tested; go-mail can include SMTP reply text. Log driver is clean (no password field, no HTML body).
Fix: Map driver errors to a stable postcard: smtp send failed (optionally errors.Is for tests) and log the verbose error at debug without username/password.
IN-02: Any extra file in LangFS fails plugin boot
File: phrasebook/loader.go:80-107,243-263
Issue: WalkDir plus fail-closed parseLangPath treats lang/en/lang.yaml.bak, README.md, or a nested lang/en/nested/x.yaml as a hard Load error naming the plugin. That is safe against traversal (../ cleans to a non-lang/a/b.yaml path). It also means //go:embed . or an editor backup in lang/ cannot boot. Mail registration only reads declared names, which is the better shape.
Fix: Skip non-matching paths (or only fs.Glob lang/*/*.yaml) instead of failing the catalog.
IN-03: data: is banned in all rendered HTML
File: postcard/templates.go:25,406-408
Issue: (javascript|vbscript|data): rejects every data URI, including data:image/png;base64,… used as inline mail images, and also fails a send whose body merely mentions javascript:. Conservative for v1; it is not XSS by itself.
Fix: Restrict the scheme check to URL attributes (href / src / xlink:href) and allow data:image/*.
Reviewed: 2026-09-18T12:15:00Z Reviewer: gsd-code-reviewer Depth: standard