// Package attach is the stub quarry writes in place of a file (RFC 0016
// §The stub) and the two policies every reader of one applies: which
// declared types an upload may carry, and which of those a page may
// draw inline.
//
// The stub is plain text so a client that does not know it shows a
// small readable file under the attachment's name; zit#187 teaches zit
// to recognise the shape. It carries no URL: the bytes are found by
// hash in whatever bucket the repository binds today, which is what
// lets a steward move buckets without touching history.
package attach
import (
"bytes"
"errors"
"fmt"
"strconv"
"strings"
)
// Header is the stub's first line, version included. A blob that does
// not start with it is a file, whatever else it says.
const Header = "quarry-attachment 1"
// MaxStubBytes bounds what a parser will read as a stub. The five
// lines run to a few hundred bytes at the longest filename fossil
// accepts; a kilobyte leaves room and keeps the fold's read small.
const MaxStubBytes = 1 << 10
// Stub names bytes that live in a bucket: what they hash to, how many
// there are, what the uploader said they were, and what the file was
// called.
type Stub struct {
SHA256 string
Size int64
Type string
Name string
}
// Format is the stub's bytes, in the RFC's order with one trailing
// newline. Every field is one line and none may hold a newline, which
// Parse enforces from the other side.
func (s Stub) Format() []byte {
var b strings.Builder
b.WriteString(Header + "\n")
b.WriteString("sha256 " + s.SHA256 + "\n")
b.WriteString("size " + strconv.FormatInt(s.Size, 10) + "\n")
b.WriteString("type " + s.Type + "\n")
b.WriteString("name " + s.Name + "\n")
return []byte(b.String())
}
// IsStub reports whether a blob's FRONT is a stub's. The header line
// is enough to decide, which is what lets the fold ask with a bounded
// read and only then read the whole (small) blob.
func IsStub(prefix []byte) bool {
return bytes.HasPrefix(prefix, []byte(Header+"\n")) || string(prefix) == Header
}
// Parse reads a stub strictly: the header first, each of the four keys
// exactly once, a 64-hex hash, an integer size, and nothing else. A
// trailing newline is tolerated because every editor adds one; a
// second one is not, because a stub is written by a program and any
// drift is a sign the bytes are not what they claim.
func Parse(raw []byte) (Stub, error) {
if len(raw) > MaxStubBytes {
return Stub{}, errors.New("attach: not a stub: too long")
}
text := strings.TrimSuffix(string(raw), "\n")
lines := strings.Split(text, "\n")
if lines[0] != Header {
return Stub{}, errors.New("attach: not a stub: no header")
}
if len(lines) != 5 {
return Stub{}, fmt.Errorf("attach: malformed stub: %d lines, want 5", len(lines))
}
var s Stub
seen := map[string]bool{}
for _, line := range lines[1:] {
key, val, ok := strings.Cut(line, " ")
if !ok || val == "" || seen[key] {
return Stub{}, fmt.Errorf("attach: malformed stub line %q", line)
}
seen[key] = true
switch key {
case "sha256":
if len(val) != 64 || strings.Trim(val, "0123456789abcdef") != "" {
return Stub{}, fmt.Errorf("attach: malformed stub: sha256 %q", val)
}
s.SHA256 = val
case "size":
n, err := strconv.ParseInt(val, 10, 64)
if err != nil || n < 0 || strconv.FormatInt(n, 10) != val {
return Stub{}, fmt.Errorf("attach: malformed stub: size %q", val)
}
s.Size = n
case "type":
s.Type = val
case "name":
s.Name = val
default:
return Stub{}, fmt.Errorf("attach: malformed stub: unknown key %q", key)
}
}
if len(seen) != 4 {
return Stub{}, errors.New("attach: malformed stub: a key is missing")
}
return s, nil
}
// Key is the object's name in the bucket: content-addressed, so the
// same bytes attached twice are one object and every reader can verify
// what it fetched against the stub.
func Key(sha256 string) string { return "attachments/" + sha256 }
// Kind is which of the four card shapes a declared type takes:
// image, text, media or other. The declared type, because a stub's
// bytes are never read by the forge — this is the one place the
// attacher's claim decides, and it decides only how a card is drawn,
// never whether the browser is handed a renderable type (see Inline).
func Kind(mimetype string) string {
t := mediaType(mimetype)
switch {
case strings.HasPrefix(t, "image/"):
return "image"
case strings.HasPrefix(t, "text/"), t == "application/json":
return "text"
case strings.HasPrefix(t, "audio/"), strings.HasPrefix(t, "video/"):
return "media"
}
return "other"
}
// policy is the type allowlist for uploads through the forge, as the
// RFC's default and one switch: what a steward may open is archives,
// and executables are never on it. The value says whether the type is
// an archive. Everything not listed is refused — the forge never sees
// the bytes, so the declared type is the one thing it can hold an
// upload to, and a list of what is allowed is shorter and safer than a
// list of what is not.
var policy = map[string]bool{
"image/png": false, "image/jpeg": false, "image/gif": false, "image/webp": false, "image/svg+xml": false,
"application/pdf": false,
"application/json": false,
"video/mp4": false, "video/webm": false, "video/ogg": false,
"audio/mpeg": false, "audio/ogg": false, "audio/wav": false, "audio/mp4": false, "audio/webm": false,
"application/zip": true, "application/gzip": true, "application/x-tar": true, "application/x-gzip": true,
}
// Allowed reports whether an upload may declare mimetype: text of any
// subtype, the listed images, documents and media, and the archives
// only when the repository opened them.
func Allowed(mimetype string, archives bool) bool {
t := mediaType(mimetype)
if strings.HasPrefix(t, "text/") {
return true
}
archive, ok := policy[t]
if !ok {
return false
}
return !archive || archives
}
// Inline reports whether a page may hand the browser this type to
// render in place — the raster images, text and the media formats.
// SVG is deliberately absent: inline SVG is script on the page's
// origin, so it is always a download.
func Inline(mimetype string) bool {
switch t := mediaType(mimetype); t {
case "image/png", "image/jpeg", "image/gif", "image/webp",
"video/mp4", "video/webm", "video/ogg", "audio/mpeg", "audio/ogg", "audio/wav", "audio/mp4", "audio/webm":
return true
default:
return strings.HasPrefix(t, "text/")
}
}
// mediaType is the type without its parameters, lower-cased: the
// policy is about "text/plain", not about "; charset=utf-8".
func mediaType(mimetype string) string {
t, _, _ := strings.Cut(mimetype, ";")
return strings.ToLower(strings.TrimSpace(t))
}