mirror of
https://codeberg.org/git-pages/git-pages.git
synced 2026-05-23 23:51:56 +00:00
543 lines
14 KiB
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
|
|
}
|
|
}
|
|
}
|
|
}
|