Files
git-pages/src/backend_fs.go

543 lines
14 KiB
Go

package git_pages
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"io"
iofs "io/fs"
"iter"
"os"
"path/filepath"
"strings"
)
type FSBackend struct {
blobRoot *os.Root
siteRoot *os.Root
auditRoot *os.Root
hasAtomicCAS bool
}
var _ Backend = (*FSBackend)(nil)
func maybeCreateOpenRoot(dir string, name string) (*os.Root, error) {
dirName := filepath.Join(dir, name)
if err := os.Mkdir(dirName, 0o755); err != nil && !errors.Is(err, os.ErrExist) {
return nil, fmt.Errorf("mkdir: %w", err)
}
root, err := os.OpenRoot(dirName)
if err != nil {
return nil, fmt.Errorf("open: %w", err)
}
return root, nil
}
func createTempInRoot(root *os.Root, name string, data []byte) (string, error) {
tempFile, err := os.CreateTemp(root.Name(), name)
if err != nil {
return "", fmt.Errorf("mktemp: %w", err)
}
_, err = tempFile.Write(data)
tempFile.Close()
if err != nil {
return "", fmt.Errorf("write: %w", err)
}
tempPath, err := filepath.Rel(root.Name(), tempFile.Name())
if err != nil {
return "", fmt.Errorf("relpath: %w", err)
}
return tempPath, nil
}
func checkAtomicCAS(root *os.Root) bool {
fileName := ".hasAtomicCAS"
file, err := root.Create(fileName)
if err != nil {
panic(err)
}
root.Remove(fileName)
defer file.Close()
flockErr := FileLock(file)
funlockErr := FileUnlock(file)
return (flockErr == nil && funlockErr == nil)
}
func NewFSBackend(ctx context.Context, config *FSConfig) (*FSBackend, error) {
blobRoot, err := maybeCreateOpenRoot(config.Root, "blob")
if err != nil {
return nil, fmt.Errorf("blob: %w", err)
}
siteRoot, err := maybeCreateOpenRoot(config.Root, "site")
if err != nil {
return nil, fmt.Errorf("site: %w", err)
}
auditRoot, err := maybeCreateOpenRoot(config.Root, "audit")
if err != nil {
return nil, fmt.Errorf("audit: %w", err)
}
hasAtomicCAS := checkAtomicCAS(siteRoot)
if hasAtomicCAS {
logc.Println(ctx, "fs: has atomic CAS")
} else {
logc.Println(ctx, "fs: has best-effort CAS")
}
return &FSBackend{blobRoot, siteRoot, auditRoot, hasAtomicCAS}, nil
}
func (fs *FSBackend) Backend() Backend {
return fs
}
func (fs *FSBackend) HasFeature(ctx context.Context, feature BackendFeature) bool {
switch feature {
case FeatureCheckDomainMarker:
return true
default:
return false
}
}
func (fs *FSBackend) EnableFeature(ctx context.Context, feature BackendFeature) error {
switch feature {
case FeatureCheckDomainMarker:
return nil
default:
return fmt.Errorf("not implemented")
}
}
func (fs *FSBackend) GetBlob(
ctx context.Context, name string,
) (
reader io.ReadSeeker, metadata BlobMetadata, err error,
) {
blobPath := filepath.Join(splitBlobName(name)...)
stat, err := fs.blobRoot.Stat(blobPath)
if errors.Is(err, os.ErrNotExist) {
err = fmt.Errorf("%w: %s", ErrObjectNotFound, err.(*os.PathError).Path)
return
} else if err != nil {
err = fmt.Errorf("stat: %w", err)
return
}
file, err := fs.blobRoot.Open(blobPath)
if err != nil {
err = fmt.Errorf("open: %w", err)
return
}
return file, BlobMetadata{name, int64(stat.Size()), stat.ModTime()}, nil
}
func (fs *FSBackend) PutBlob(ctx context.Context, name string, data []byte) error {
blobPath := filepath.Join(splitBlobName(name)...)
blobDir := filepath.Dir(blobPath)
if _, err := fs.blobRoot.Stat(blobPath); err == nil {
// Blob already exists. While on Linux it would be benign to write and replace a blob
// that already exists, on Windows this is liable to cause access errors.
return nil
}
tempPath, err := createTempInRoot(fs.blobRoot, name, data)
if err != nil {
return err
}
if err := fs.blobRoot.Chmod(tempPath, 0o444); err != nil {
if errors.Is(err, os.ErrPermission) {
// NFSv4 configured with ACLs doesn't have a working `chmod` even though it's a Unix
// system. This `chmod` call is done entirely for convenience (to help the system
// administrator avoid accidentally overwriting files), so just skip it.
} else {
return fmt.Errorf("chmod: %w", err)
}
}
again:
for {
if err := fs.blobRoot.MkdirAll(blobDir, 0o755); err != nil {
if errors.Is(err, os.ErrExist) {
// Handle the case where two `PutBlob()` calls race creating a common prefix
// of a blob directory. The `MkdirAll()` call that loses the TOCTTOU condition
// bails out, so we have to repeat it.
continue again
}
return fmt.Errorf("mkdir: %w", err)
}
break
}
if err := fs.blobRoot.Rename(tempPath, blobPath); err != nil {
return fmt.Errorf("rename: %w", err)
}
return nil
}
func (fs *FSBackend) DeleteBlob(ctx context.Context, name string) error {
blobPath := filepath.Join(splitBlobName(name)...)
return fs.blobRoot.Remove(blobPath)
}
func (fs *FSBackend) EnumerateBlobs(ctx context.Context) iter.Seq2[BlobMetadata, error] {
return func(yield func(BlobMetadata, error) bool) {
iofs.WalkDir(fs.blobRoot.FS(), ".",
func(path string, entry iofs.DirEntry, err error) error {
var metadata BlobMetadata
if err != nil {
// report error
} else if entry.IsDir() {
// skip directory
return nil
} else if info, err := entry.Info(); err != nil {
// report error
} else {
// report blob
metadata.Name = joinBlobName(strings.Split(path, "/"))
metadata.Size = info.Size()
metadata.LastModified = info.ModTime()
}
if !yield(metadata, err) {
return iofs.SkipAll
}
return nil
})
}
}
func (fs *FSBackend) GetManifest(
ctx context.Context, name string, opts GetManifestOptions,
) (
manifest *Manifest, metadata ManifestMetadata, err error,
) {
stat, err := fs.siteRoot.Stat(name)
if errors.Is(err, os.ErrNotExist) {
err = fmt.Errorf("%w: %s", ErrObjectNotFound, err.(*os.PathError).Path)
return
} else if err != nil {
err = fmt.Errorf("stat: %w", err)
return
}
data, err := fs.siteRoot.ReadFile(name)
if err != nil {
err = fmt.Errorf("read: %w", err)
return
}
manifest, err = DecodeManifest(data)
if err != nil {
return
}
return manifest, ManifestMetadata{
LastModified: stat.ModTime(),
ETag: fmt.Sprintf("%x", sha256.Sum256(data)),
}, nil
}
func stagedManifestName(manifestData []byte) string {
return fmt.Sprintf(".%x", sha256.Sum256(manifestData))
}
func (fs *FSBackend) StageManifest(ctx context.Context, manifest *Manifest) error {
manifestData := EncodeManifest(manifest)
tempPath, err := createTempInRoot(fs.siteRoot, ".manifest", manifestData)
if err != nil {
return err
}
if err := fs.siteRoot.Rename(tempPath, stagedManifestName(manifestData)); err != nil {
return fmt.Errorf("rename: %w", err)
}
return nil
}
func domainFrozenMarkerName(domain string) string {
return filepath.Join(domain, ".frozen")
}
func (fs *FSBackend) checkDomainFrozen(ctx context.Context, domain string) error {
if _, err := fs.siteRoot.Stat(domainFrozenMarkerName(domain)); err == nil {
return ErrDomainFrozen
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("stat: %w", err)
} else {
return nil
}
}
func (fs *FSBackend) HasAtomicCAS(ctx context.Context) bool {
// On a suitable filesystem, POSIX advisory locks can be used to implement atomic CAS.
// An implementation consists of two parts:
// - Intra-process mutex set (one per manifest), to prevent races between goroutines;
// - Inter-process POSIX advisory locks (one per manifest), to prevent races between
// different git-pages instances.
return fs.hasAtomicCAS
}
type manifestLockGuard struct {
file *os.File
}
func lockManifest(fs *os.Root, name string) (*manifestLockGuard, error) {
file, err := fs.Open(name)
if errors.Is(err, os.ErrNotExist) {
return &manifestLockGuard{nil}, nil
} else if err != nil {
return nil, fmt.Errorf("open: %w", err)
}
if err := FileLock(file); err != nil {
file.Close()
return nil, fmt.Errorf("flock(LOCK_EX): %w", err)
}
return &manifestLockGuard{file}, nil
}
func (guard *manifestLockGuard) Unlock() {
if guard.file != nil {
FileUnlock(guard.file)
guard.file.Close()
}
}
func (fs *FSBackend) checkManifestPrecondition(
ctx context.Context, name string, opts ModifyManifestOptions,
) error {
if !opts.IfUnmodifiedSince.IsZero() {
stat, err := fs.siteRoot.Stat(name)
if err != nil {
return fmt.Errorf("stat: %w", err)
}
if stat.ModTime().Compare(opts.IfUnmodifiedSince) > 0 {
return fmt.Errorf("%w: If-Unmodified-Since", ErrPreconditionFailed)
}
}
if opts.IfMatch != "" {
data, err := fs.siteRoot.ReadFile(name)
if err != nil {
return fmt.Errorf("read: %w", err)
}
if fmt.Sprintf("%x", sha256.Sum256(data)) != opts.IfMatch {
return fmt.Errorf("%w: If-Match", ErrPreconditionFailed)
}
}
return nil
}
func (fs *FSBackend) CommitManifest(
ctx context.Context, name string, manifest *Manifest, opts ModifyManifestOptions,
) error {
if fs.hasAtomicCAS {
if guard, err := lockManifest(fs.siteRoot, name); err != nil {
return err
} else {
defer guard.Unlock()
}
}
domain := filepath.Dir(name)
if err := fs.checkDomainFrozen(ctx, domain); err != nil {
return err
}
if err := fs.checkManifestPrecondition(ctx, name, opts); err != nil {
return err
}
manifestData := EncodeManifest(manifest)
manifestHashName := stagedManifestName(manifestData)
if _, err := fs.siteRoot.Stat(manifestHashName); err != nil {
return fmt.Errorf("manifest not staged")
}
if err := fs.siteRoot.MkdirAll(domain, 0o755); err != nil {
return fmt.Errorf("mkdir: %w", err)
}
if err := fs.siteRoot.Rename(manifestHashName, name); err != nil {
return fmt.Errorf("rename: %w", err)
}
return nil
}
func (fs *FSBackend) DeleteManifest(
ctx context.Context, name string, opts ModifyManifestOptions,
) error {
if fs.hasAtomicCAS {
if guard, err := lockManifest(fs.siteRoot, name); err != nil {
return err
} else {
defer guard.Unlock()
}
}
domain := filepath.Dir(name)
if err := fs.checkDomainFrozen(ctx, domain); err != nil {
return err
}
if err := fs.checkManifestPrecondition(ctx, name, opts); err != nil {
return err
}
err := fs.siteRoot.Remove(name)
if errors.Is(err, os.ErrNotExist) {
return nil
} else {
return err
}
}
func (fs *FSBackend) EnumerateManifests(ctx context.Context) iter.Seq2[*ManifestMetadata, error] {
return func(yield func(*ManifestMetadata, error) bool) {
iofs.WalkDir(fs.siteRoot.FS(), ".",
func(path string, entry iofs.DirEntry, err error) error {
_, project, _ := strings.Cut(path, "/")
var metadata *ManifestMetadata
if err != nil {
// report error
} else if entry.IsDir() {
// skip directory
return nil
} else if project == "" || strings.HasPrefix(project, ".") && project != ".index" {
// skip internal
return nil
} else if info, err := entry.Info(); err != nil {
// report error
} else {
// report blob
metadata = &ManifestMetadata{
Name: path,
Size: info.Size(),
LastModified: info.ModTime(),
}
// not setting metadata.ETag since it is too costly
}
if !yield(metadata, err) {
return iofs.SkipAll
}
return nil
})
}
}
func (fs *FSBackend) GetAllManifests(ctx context.Context) iter.Seq2[tuple[*ManifestMetadata, *Manifest], error] {
return func(yield func(tuple[*ManifestMetadata, *Manifest], error) bool) {
for metadata, err := range fs.EnumerateManifests(ctx) {
var item tuple[*ManifestMetadata, *Manifest]
if err == nil {
var manifest *Manifest
manifest, _, err = backend.GetManifest(ctx, metadata.Name, GetManifestOptions{})
item = tuple[*ManifestMetadata, *Manifest]{metadata, manifest}
}
if !yield(item, err) {
break
}
}
}
}
func (fs *FSBackend) CheckDomain(ctx context.Context, domain string) (bool, error) {
_, err := fs.siteRoot.Stat(domain)
if errors.Is(err, os.ErrNotExist) {
return false, nil
} else if err == nil {
return true, nil
} else {
return false, err
}
}
func (fs *FSBackend) CreateDomain(ctx context.Context, domain string) error {
return nil // no-op
}
func (fs *FSBackend) FreezeDomain(ctx context.Context, domain string) error {
return fs.siteRoot.WriteFile(domainFrozenMarkerName(domain), []byte{}, 0o644)
}
func (fs *FSBackend) UnfreezeDomain(ctx context.Context, domain string) error {
err := fs.siteRoot.Remove(domainFrozenMarkerName(domain))
if errors.Is(err, os.ErrNotExist) {
return nil
} else {
return err
}
}
func (fs *FSBackend) AppendAuditLog(ctx context.Context, id AuditID, record *AuditRecord) error {
if _, err := fs.auditRoot.Stat(id.String()); err == nil {
panic(fmt.Errorf("audit ID collision: %s", id))
}
return fs.auditRoot.WriteFile(id.String(), EncodeAuditRecord(record), 0o644)
}
func (fs *FSBackend) QueryAuditLog(ctx context.Context, id AuditID) (*AuditRecord, error) {
if data, err := fs.auditRoot.ReadFile(id.String()); err != nil {
return nil, fmt.Errorf("read: %w", err)
} else if record, err := DecodeAuditRecord(data); err != nil {
return nil, fmt.Errorf("decode: %w", err)
} else {
return record, nil
}
}
func (fs *FSBackend) SearchAuditLog(
ctx context.Context, opts SearchAuditLogOptions,
) iter.Seq2[AuditID, error] {
return func(yield func(AuditID, error) bool) {
iofs.WalkDir(fs.auditRoot.FS(), ".",
func(path string, entry iofs.DirEntry, err error) error {
if path == "." {
return nil // skip
}
var id AuditID
if err != nil {
// report error
} else if id, err = ParseAuditID(path); err != nil {
// report error
} else if !opts.Since.IsZero() && id.CompareTime(opts.Since) < 0 {
return nil // skip
} else if !opts.Until.IsZero() && id.CompareTime(opts.Until) > 0 {
return nil // skip
}
if !yield(id, err) {
return iofs.SkipAll // break
} else {
return nil // continue
}
})
}
}
func (fs *FSBackend) GetAuditLogRecords(
ctx context.Context, ids iter.Seq2[AuditID, error],
) iter.Seq2[*AuditRecord, error] {
return func(yield func(*AuditRecord, error) bool) {
for id, err := range ids {
var record *AuditRecord
if err == nil {
record, err = fs.QueryAuditLog(ctx, id)
}
if !yield(record, err) {
break
}
}
}
}