208 lines
6.8 KiB
Go
208 lines
6.8 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html"
|
|
"io"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// integrateDocs installs only the guide HTML and its three small host assets.
|
|
// The editor, examples and WASM remain in the single pinned release artifact.
|
|
// Existing different files are never replaced, and all destinations are rooted
|
|
// in the caller's chosen site directory.
|
|
func integrateDocs(dir, site, expected string) (map[string]any, error) {
|
|
m, hash, err := verify(dir, expected)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
files := make(map[string]resource, len(m.Files))
|
|
for _, file := range m.Files {
|
|
files[file.Path] = file
|
|
}
|
|
artifact, err := os.OpenRoot(dir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer artifact.Close()
|
|
readVerified := func(name string) ([]byte, error) {
|
|
want, ok := files[name]
|
|
if !ok {
|
|
return nil, fmt.Errorf("resource missing from manifest: %s", name)
|
|
}
|
|
st, err := artifact.Lstat(name)
|
|
if err != nil || !st.Mode().IsRegular() || st.Size() != want.Bytes {
|
|
return nil, fmt.Errorf("invalid artifact resource: %s", name)
|
|
}
|
|
f, err := artifact.Open(name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
b, err := io.ReadAll(io.LimitReader(f, want.Bytes+1))
|
|
if err != nil || int64(len(b)) != want.Bytes || digest(b) != want.SHA256 {
|
|
return nil, fmt.Errorf("artifact resource changed: %s", name)
|
|
}
|
|
return b, nil
|
|
}
|
|
data, err := readVerified("docs/integration.json")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var spec struct {
|
|
BuildID, EditorURL, EmbedScript, ProtocolScript string
|
|
Protocol int
|
|
}
|
|
if err := json.Unmarshal(data, &spec); err != nil {
|
|
return nil, err
|
|
}
|
|
editor := m.AppOrigin + "/r/" + m.BuildID + "/"
|
|
if spec.BuildID != m.BuildID || spec.Protocol != m.ProtocolVersion || spec.EditorURL != editor {
|
|
return nil, fmt.Errorf("documentation metadata does not match manifest")
|
|
}
|
|
if !strings.HasPrefix(spec.EmbedScript, "assets/docs-embed.") || !strings.HasSuffix(spec.EmbedScript, ".js") || !strings.HasPrefix(spec.ProtocolScript, "assets/protocol.") || !strings.HasSuffix(spec.ProtocolScript, ".js") {
|
|
return nil, fmt.Errorf("documentation metadata names unexpected host scripts")
|
|
}
|
|
var style string
|
|
for _, file := range m.Files {
|
|
if strings.HasPrefix(file.Path, "assets/docs.") && strings.HasSuffix(file.Path, ".css") && hashedFile.MatchString(file.Path) {
|
|
if style != "" {
|
|
return nil, fmt.Errorf("ambiguous guide stylesheet")
|
|
}
|
|
style = file.Path
|
|
}
|
|
}
|
|
for _, file := range []string{spec.EmbedScript, spec.ProtocolScript, style} {
|
|
if _, ok := files[file]; !ok || !strings.HasPrefix(file, "assets/") || !hashedFile.MatchString(file) {
|
|
return nil, fmt.Errorf("missing or unversioned guide asset %q", file)
|
|
}
|
|
}
|
|
if len(m.Examples) == 0 {
|
|
return nil, fmt.Errorf("artifact has no lesson examples")
|
|
}
|
|
items := map[string][]byte{}
|
|
for _, file := range []string{spec.EmbedScript, spec.ProtocolScript, style} {
|
|
b, err := readVerified(file)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items[file] = b
|
|
}
|
|
for _, example := range m.Examples {
|
|
for _, language := range []string{"en", "ru"} {
|
|
rel := path.Join("docs", language, example.ID, "index.html")
|
|
if _, ok := files[rel]; !ok {
|
|
return nil, fmt.Errorf("guide missing from manifest: %s", rel)
|
|
}
|
|
b, err := readVerified(rel)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, marker := range []string{`data-editor="` + html.EscapeString(editor) + `"`, `data-build="` + html.EscapeString(m.BuildID) + `"`, "../../../" + style, "../../../" + spec.EmbedScript, "../../../" + spec.ProtocolScript} {
|
|
if !bytes.Contains(b, []byte(marker)) {
|
|
return nil, fmt.Errorf("guide %s does not reference pinned integration: %s", rel, marker)
|
|
}
|
|
}
|
|
items[path.Join("asset-studio", language, example.ID, "index.html")] = b
|
|
}
|
|
}
|
|
manuals := 0
|
|
for _, file := range m.Files {
|
|
if (strings.HasPrefix(file.Path, "docs/en/guide/") || strings.HasPrefix(file.Path, "docs/ru/guide/")) && strings.HasSuffix(file.Path, "/index.html") {
|
|
b, err := readVerified(file.Path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !bytes.Contains(b, []byte("../../../../"+style)) {
|
|
return nil, fmt.Errorf("manual missing verified style: %s", file.Path)
|
|
}
|
|
items["asset-studio/"+strings.TrimPrefix(file.Path, "docs/")] = b
|
|
manuals++
|
|
}
|
|
}
|
|
items["asset-studio/integration.json"] = jsonBytes(map[string]any{"buildID": m.BuildID, "editorURL": editor, "protocol": m.ProtocolVersion, "manifestSHA256": hash})
|
|
if err := noSymlinkPath(site); err != nil {
|
|
return nil, err
|
|
}
|
|
artifactAbs, err := filepath.Abs(dir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
siteAbs, err := filepath.Abs(site)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if siteAbs == artifactAbs || strings.HasPrefix(siteAbs, artifactAbs+string(filepath.Separator)) {
|
|
return nil, fmt.Errorf("documentation output cannot be inside the immutable artifact")
|
|
}
|
|
root, err := os.OpenRoot(site)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer root.Close()
|
|
// Check the complete destination set before writing any file. An identical
|
|
// prior result may be reused, but a conflicting file must be kept untouched.
|
|
for rel, want := range items {
|
|
parts := strings.Split(rel, "/")
|
|
for i := 1; i < len(parts); i++ {
|
|
parent := path.Join(parts[:i]...)
|
|
st, err := root.Lstat(parent)
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return nil, err
|
|
}
|
|
if err == nil && (!st.IsDir() || st.Mode()&os.ModeSymlink != 0) {
|
|
return nil, fmt.Errorf("unsafe site parent: %s", parent)
|
|
}
|
|
}
|
|
st, err := root.Lstat(rel)
|
|
if os.IsNotExist(err) {
|
|
continue
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !st.Mode().IsRegular() || st.Size() > maxFileBytes {
|
|
return nil, fmt.Errorf("site destination is not a regular file: %s", rel)
|
|
}
|
|
old, err := root.ReadFile(rel)
|
|
if err != nil || !bytes.Equal(old, want) {
|
|
return nil, fmt.Errorf("site destination already differs: %s", rel)
|
|
}
|
|
}
|
|
for rel, body := range items {
|
|
if st, err := root.Lstat(rel); err == nil {
|
|
if !st.Mode().IsRegular() || st.Size() > maxFileBytes {
|
|
return nil, fmt.Errorf("site destination changed during integration: %s", rel)
|
|
}
|
|
old, readErr := root.ReadFile(rel)
|
|
if readErr != nil || !bytes.Equal(old, body) {
|
|
return nil, fmt.Errorf("site destination changed during integration: %s", rel)
|
|
}
|
|
continue
|
|
} else if !os.IsNotExist(err) {
|
|
return nil, err
|
|
}
|
|
if err := root.MkdirAll(path.Dir(rel), 0o755); err != nil {
|
|
return nil, err
|
|
}
|
|
f, err := root.OpenFile(rel, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
_, writeErr := f.Write(body)
|
|
closeErr := f.Close()
|
|
if writeErr != nil {
|
|
return nil, writeErr
|
|
}
|
|
if closeErr != nil {
|
|
return nil, closeErr
|
|
}
|
|
}
|
|
return map[string]any{"status": "PASS", "buildID": m.BuildID, "editorURL": editor, "manifestSHA256": hash, "guides": len(m.Examples) * 2, "manuals": manuals}, nil
|
|
}
|