blob: ff32ab1f5782a4fd02e1530554aa1b6bae497f9b [file] [log] [blame]
// Package scrap defines the scrap types and functions on them.
package scrap
import (
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"path"
"regexp"
"cloud.google.com/go/storage"
"go.skia.org/infra/go/gcs"
"go.skia.org/infra/go/skerr"
"go.skia.org/infra/go/util"
)
const maxScrapSize = 128 * 1024
var (
ErrInvalidScrapType = errors.New("Invalid scrap type.")
ErrInvalidScrapName = errors.New("Invalid scrap name.")
ErrInvalidLanguage = errors.New("Invalid language.")
ErrInvalidHash = errors.New("Invalid SHA256 hash.")
ErrInvalidScrapSize = errors.New("Scrap is too large.")
)
// SHA256 is a SHA 256 hash encoded in hex.
type SHA256 string
// Type identifies the type of a scrap.
type Type string
const (
// SVG scrap.
SVG Type = "svg"
// SKSL scrap.
SKSL Type = "sksl"
// Particle scrap.
Particle Type = "particle"
// UnknownType type of scrap.
UnknownType Type = ""
)
// AllTypes is a slice of supported Types.
var AllTypes = []Type{SVG, SKSL, Particle}
// ToType converts a string to a Type, returning UnknownType if it is not a
// valid Type.
func ToType(s string) Type {
for _, t := range AllTypes {
if string(t) == s {
return t
}
}
return UnknownType
}
// validateType returns true if t is a valid Type.
func validateType(t Type) error {
if ToType(string(t)) == UnknownType {
return skerr.Wrapf(ErrInvalidScrapType, "got: %q", t)
}
return nil
}
// Lang is a programming language a scrap can be embedded in.
type Lang string
const (
// CPP is the C++ language.
CPP Lang = "cpp"
// JS is the Javascript language.
JS Lang = "js"
// UnknownLang is an unknown language.
UnknownLang Lang = ""
)
// AllLangs is the list of all supported Langs.
var AllLangs = []Lang{CPP, JS}
// ToLang converts a string to a Lang, returning UnknownLang if it not a valid
// Lang.
func ToLang(s string) Lang {
for _, l := range AllLangs {
if string(l) == s {
return l
}
}
return UnknownLang
}
// validateLang returns true if l is a valid Lang.
func validateLang(l Lang) error {
if ToLang(string(l)) == UnknownLang {
return skerr.Wrapf(ErrInvalidLanguage, "got: %q", l)
}
return nil
}
// MimeTypes for each scrap type when served raw.
var MimeTypes = map[Type]string{
SVG: "image/svg+xml",
SKSL: "text/plain",
Particle: "application/json",
}
// SVGMetaData is metadata for SVG scraps.
type SVGMetaData struct {
}
// Uniform is a single uniform for an SkSL shader.
type Uniform struct {
Name string
Value float64
}
// ChildShader is the scrap id of a single child shader along with the name that
// the uniform should have to access it.
type ChildShader struct {
UniformName string
ScrapHashOrName string
}
// SKSLMetaData is metadata for SKSL scraps.
type SKSLMetaData struct {
// Uniforms are all the inputs to the shader.
Uniforms []float32
// Child shaders. A slice because order is important when mapping uniform
// names in code to child shaders passed to makeShaderWithChildren.
Children []ChildShader
}
// ParticlesMetaData is metadata for Particle scraps.
type ParticlesMetaData struct {
}
// ScrapBody is the body of scrap stored in GCS and transported by the API.
type ScrapBody struct {
Type Type
Body string
// Type specific metadata:
SVGMetaData *SVGMetaData `json:",omitempty"`
SKSLMetaData *SKSLMetaData `json:",omitempty"`
ParticlesMetaData *ParticlesMetaData `json:",omitempty"`
}
// ScrapID contains the identity of a newly created scrap.
type ScrapID struct {
Hash SHA256
}
// Name has information about a single named scrap.
type Name struct {
Hash SHA256
Description string
}
// ScrapExchange handles reading and writing scraps.
type ScrapExchange interface {
// Expand the given scrap into a full program in the given language and write
// that code to the given io.Writer.
Expand(ctx context.Context, t Type, hashOrName string, lang Lang, w io.Writer) error
// LoadScrap loads a scrap. The 'name' can be either a hash, or if prefixed with
// an "@" it is the name of scrap.
LoadScrap(ctx context.Context, t Type, hashOrName string) (ScrapBody, error)
// CreateScrap and return the hash by the ScrapID.
CreateScrap(ctx context.Context, scrap ScrapBody) (ScrapID, error)
// DeleteScrap and also delete the name if hashOrName is a name, which is indicated by
// the prefix "@".
DeleteScrap(ctx context.Context, t Type, hashOrName string) error
// PutName creates or updates a name for a given scrap.
PutName(ctx context.Context, t Type, name string, nameBody Name) error
// GetName retrieves the hash for the given named scrap.
GetName(ctx context.Context, t Type, name string) (Name, error)
// DeleteName removes the name for the given named scrap.
DeleteName(ctx context.Context, t Type, name string) error
// ListNames lists all the known names for a given type of scrap.
ListNames(ctx context.Context, t Type) ([]string, error)
}
// scrapExchange implements ScrapExchange.
type scrapExchange struct {
client gcs.GCSClient
templates templateMap
}
// New returns a new instance of ScrapExchange.
func New(client gcs.GCSClient) (*scrapExchange, error) {
tmpl, err := loadTemplates()
if err != nil {
return nil, skerr.Wrapf(err, "Failed to parse templates")
}
return &scrapExchange{
client: client,
templates: tmpl,
}, nil
}
var validName = regexp.MustCompile("^@[0-9a-zA-Z-_]+$")
// validateName returns true if s is a valid name for a scrap.
func validateName(s string) error {
if !validName.MatchString(s) {
return skerr.Wrapf(ErrInvalidScrapName, "got: %q", s)
}
return nil
}
var validSHA256Hash = regexp.MustCompile("^[0-9a-f]{64}$")
// isValidHash returns true if s is a valid SHA256 hash.
func isValidHash(s SHA256) bool {
return validSHA256Hash.MatchString(string(s))
}
// validate returns an error if the given scrap is invalid.
//
// validate may also modify the scrap to make it valid, so call this before
// calculating the hash for a scrap.
func (s *scrapExchange) validate(scrap *ScrapBody) error {
if err := validateType(scrap.Type); err != nil {
return err
}
return nil
}
// Expand the given scrap into a full program in the given language and write
// that code to the given io.Writer.
func (s *scrapExchange) Expand(ctx context.Context, t Type, hashOrName string, lang Lang, w io.Writer) error {
if err := validateLang(lang); err != nil {
return err
}
scrapBody, err := s.LoadScrap(ctx, t, hashOrName)
if err != nil {
return skerr.Wrapf(err, "Failed to load scrap.")
}
// TODO(jcgregorio) Add helpers to the template to allow recursively loading child scraps.
err = s.templates[lang][t].Execute(w, scrapBody)
if err != nil {
return skerr.Wrapf(err, "Failed to expand template.")
}
return nil
}
// LoadScrap loads a scrap. The 'name' can be either a hash, or if prefixed with
// an "@" it is the name of scrap.
func (s *scrapExchange) LoadScrap(ctx context.Context, t Type, hashOrName string) (ScrapBody, error) {
var ret ScrapBody
if err := validateType(t); err != nil {
return ret, err
}
var hash SHA256
if err := validateName(hashOrName); err == nil {
name := hashOrName
nameBody, err := s.GetName(ctx, t, name)
if err != nil {
return ret, skerr.Wrapf(err, "Failed to get hash of name to load.")
}
hash = nameBody.Hash
} else {
hash = SHA256(hashOrName)
}
if !isValidHash(hash) {
return ret, skerr.Wrap(ErrInvalidHash)
}
rc, err := s.client.FileReader(ctx, fmt.Sprintf("scraps/%s/%s", t, hash))
if err != nil {
return ret, skerr.Wrapf(err, "Failed to open scrap.")
}
defer util.Close(rc)
if err := json.NewDecoder(rc).Decode(&ret); err != nil {
return ret, skerr.Wrapf(err, "Failed to decode scrap.")
}
return ret, nil
}
// CreateScrap and return the hash by the ScrapID.
func (s *scrapExchange) CreateScrap(ctx context.Context, scrap ScrapBody) (ScrapID, error) {
var ret ScrapID
if err := s.validate(&scrap); err != nil {
return ret, skerr.Wrapf(err, "Invalid scrap.")
}
var b bytes.Buffer
if err := json.NewEncoder(&b).Encode(scrap); err != nil {
return ret, skerr.Wrapf(err, "Failed to JSON encode scrap.")
}
if b.Len() > maxScrapSize {
return ret, ErrInvalidScrapSize
}
unencodedBody := b.Bytes()
hashAsByteArray := sha256.Sum256(unencodedBody)
hash := hex.EncodeToString(hashAsByteArray[:])
ret.Hash = SHA256(hash)
w := s.client.FileWriter(ctx, fmt.Sprintf("scraps/%s/%s", scrap.Type, hash), gcs.FileWriteOptions{
ContentEncoding: "gzip",
ContentType: "application/json",
})
zw := gzip.NewWriter(w)
_, err := zw.Write(unencodedBody)
if err != nil {
return ret, skerr.Wrapf(err, "Failed to write JSON body.")
}
if err := zw.Close(); err != nil {
return ret, skerr.Wrapf(err, "Failed to close gzip writer.")
}
if err := w.Close(); err != nil {
return ret, skerr.Wrapf(err, "Failed to close GCS Storage writer.")
}
return ret, nil
}
// DeleteScrap and also delete the name if hashOrName is a name, which is indicated by
// the prefix "@".
func (s *scrapExchange) DeleteScrap(ctx context.Context, t Type, hashOrName string) error {
if err := validateType(t); err != nil {
return err
}
var hash SHA256
if err := validateName(hashOrName); err == nil {
name := hashOrName
nameBody, err := s.GetName(ctx, t, name)
if err != nil {
return skerr.Wrapf(err, "Failed to get hash of name to delete.")
}
err = s.DeleteName(ctx, t, name)
if err != nil {
return skerr.Wrapf(err, "Failed to delete name.")
}
hash = nameBody.Hash
} else {
hash = SHA256(hashOrName)
}
if !isValidHash(hash) {
return skerr.Wrap(ErrInvalidHash)
}
err := s.client.DeleteFile(ctx, fmt.Sprintf("scraps/%s/%s", t, hash))
if err != nil {
return skerr.Wrapf(err, "Failed to delete hash.")
}
return nil
}
// PutName creates or updates a name for a given scrap.
func (s *scrapExchange) PutName(ctx context.Context, t Type, name string, nameBody Name) error {
if err := validateType(t); err != nil {
return err
}
if err := validateName(name); err != nil {
return err
}
if !isValidHash(nameBody.Hash) {
return skerr.Wrap(ErrInvalidHash)
}
w := s.client.FileWriter(ctx, fmt.Sprintf("names/%s/%s", t, name), gcs.FileWriteOptions{
ContentType: "application/json",
})
if err := json.NewEncoder(w).Encode(nameBody); err != nil {
return skerr.Wrapf(err, "Failed to encode JSON.")
}
if err := w.Close(); err != nil {
return skerr.Wrapf(err, "Failed to close GCS Storage writer.")
}
return nil
}
// GetName retrieves the hash for the given named scrap.
func (s *scrapExchange) GetName(ctx context.Context, t Type, name string) (Name, error) {
var ret Name
if err := validateType(t); err != nil {
return ret, err
}
if err := validateName(name); err != nil {
return ret, err
}
rc, err := s.client.FileReader(ctx, fmt.Sprintf("names/%s/%s", t, name))
if err != nil {
return ret, skerr.Wrapf(err, "Failed to open name.")
}
defer util.Close(rc)
if err := json.NewDecoder(rc).Decode(&ret); err != nil {
return ret, skerr.Wrapf(err, "Failed to decode body.")
}
return ret, nil
}
// DeleteName removes the name for the given named scrap.
func (s *scrapExchange) DeleteName(ctx context.Context, t Type, name string) error {
if err := validateType(t); err != nil {
return err
}
if err := validateName(name); err != nil {
return err
}
err := s.client.DeleteFile(ctx, fmt.Sprintf("names/%s/%s", t, name))
if err != nil {
return skerr.Wrapf(err, "Failed to delete name.")
}
return nil
}
// ListNames lists all the known names for a given type of scrap.
func (s *scrapExchange) ListNames(ctx context.Context, t Type) ([]string, error) {
ret := []string{}
if err := validateType(t); err != nil {
return nil, err
}
err := s.client.AllFilesInDirectory(ctx, fmt.Sprintf("names/%s/", t), func(item *storage.ObjectAttrs) {
// GCS always uses forward slashes.
name := path.Base(item.Name)
ret = append(ret, name)
})
if err != nil {
return nil, skerr.Wrapf(err, "Failed to read directory.")
}
return ret, nil
}
// Confirm that scrapExchange implements the ScrapExchange interface.
var _ ScrapExchange = (*scrapExchange)(nil)