feat(canary): pdf generator (byte-replace fixed-width URI action)
Generator runs at Token-create time. Substitutes the 76-byte placeholder
in the embedded template.pdf with the canary trigger URL padded to the
same byte length. Length preservation is critical: the PDF's xref table
indexes objects by byte offset, so anything that shifts bytes after the
catalog corrupts the file.
Algorithm (spec §9.4 lines 1145-1165):
triggerURL := strings.TrimRight(baseURL, "/") + "/c/" + t.ID
if len(triggerURL) > 76 → ErrTriggerURLTooLong
padded := triggerURL + strings.Repeat("_", 76 - len(triggerURL))
out := bytes.Replace(pdfTemplate, placeholder, padded, 1)
Defensive checks after substitution:
- len(out) == len(pdfTemplate) (xref offsets stay valid)
- bytes.Contains(out, triggerURL) (substitution actually happened)
Trigger is a content-copy of webbug's response (sanctioned per Phase
2/3/4 anti-relitigation set): 200 + 43-byte pixel.Clone() + Cache-Control
+ Pragma headers. Nil-token path returns the same artifact shape with no
event (spec §8.5 defense-in-depth). realIP / lastNonEmptyXFF /
optionalHeader are duplicated byte-identically with docx and slowredirect
— Phase 9 collapses this into a middleware helper.
Test suite (~32 cases, mirrors the docx Phase 4 shape plus PDF-specific
structural guards):
Template invariants (5):
- placeholder root byte-locatable exactly once (literal, not encoded)
- full 76-byte placeholder byte-locatable exactly once
- starts with %PDF- header
- ends with %%EOF marker
- pdfcpu.api.Validate passes on the raw template
Generator behavior (10):
- Type() is TypePDF, Artifact.Kind is KindFile
- ContentType is application/pdf
- Filename defaulting (nil / empty / whitespace / set)
- trigger URL precedence (trailing-slash trim, subpath preserve,
distinct ids → distinct outputs)
- output length == template length (xref preservation)
- output contains trigger URL
- placeholder root removed from output
- URL > 76 chars returns ErrTriggerURLTooLong (errors.Is)
- boundary URL at exactly 76 chars succeeds (zero padding)
- api.Validate still passes on substituted output
- substitution preserves byte offset (URL occupies the same position
the placeholder did)
Trigger behavior (~16 incl subtests):
- response shape identical to webbug (200 + pixel.Clone + headers)
- records event with token id / source IP / UA / Referer
- source-IP precedence (CF > XFF rightmost > XRI > RemoteAddr),
11 subcases mirroring docx + webbug for full symmetry
- missing UA/Referer → nil pointers
- per-call body is an independent slice (mutate one, other unchanged)
- nil-token path returns GIF + nil event (FK-safe)
go test -race -timeout=60s ./internal/token/generators/pdf/... passes.