package release
import (
"errors"
"fmt"
"io/fs"
"log"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/carlosframework/platform/internal/safefile"
)
// RequirePointerDigestEnv is the box flag that refuses every channel pointer
// without digests, even for a channel this box has never seen (spec §4.6, the
// fleet-wide switch). Off by default; it goes on once `carlos channels
// digests` lists nothing.
const RequirePointerDigestEnv = "CARLOS_REQUIRE_POINTER_DIGEST"
// ErrPointerWithoutDigests is a pointer the ratchet or the flag refuses.
var ErrPointerWithoutDigests = errors.New("release: pointer carries no digests")
// ErrRatchetUnpersisted is a pointer with digests whose channel mark could
// not be written: nothing installs or extracts on it until the mark is
// durable, because a restart would otherwise forget the channel (Astra I1 #4).
var ErrRatchetUnpersisted = errors.New("release: the pointer digest mark could not be persisted")
// errRatchetMarkUnreadable marks Admit's refusal of a pointer without
// digests because the channel's mark could not be read: unlike the flag or
// a mark that is there, it is not a verdict, so a caller that can retry
// (the held stage) does. markUnreadable carries it without changing the
// error's text.
var errRatchetMarkUnreadable = errors.New("release: the pointer digest mark could not be read")
type markUnreadable struct{ err error }
func (e markUnreadable) Error() string { return e.err.Error() }
func (e markUnreadable) Unwrap() error { return e.err }
func (markUnreadable) Is(target error) bool { return target == errRatchetMarkUnreadable }
// RequirePointerDigest reads the flag: 1, true, on or yes in any case.
func RequirePointerDigest(getenv func(string) string) bool {
switch strings.ToLower(strings.TrimSpace(getenv(RequirePointerDigestEnv))) {
case "1", "true", "on", "yes":
return true
}
return false
}
// RootRatchetDir is root's marks (/opt/carlos/pointer-digests), and
// EdgeRatchetDir the sandboxed agent's and edge's, which cannot write /opt
// (/data/pointer-digests). Each process writes its own and reads both. A mark
// is one flat file per channel,
///.digests, with
// every "/" in the channel written as "%2F" (see markPath).
func RootRatchetDir(optDir string) string { return filepath.Join(optDir, "pointer-digests") }
func EdgeRatchetDir(dataDir string) string { return filepath.Join(dataDir, "pointer-digests") }
// DigestRatchet is the per-box read-side protection of #253 (spec §4.6):
// once this box has verified a pointer with digests for a channel, it
// refuses pointers without them for that channel, so a saved old pointer
// cannot be replayed to it. Marks are files, so they outlive the process;
// they are never cleared, and removing the emission marker does not clear
// them.
type DigestRatchet struct {
own string
also []string
// require is atomic: SharedDigestRatchet (Task 8) may widen it while
// another goroutine's Admit reads it (Astra I3 #7).
require atomic.Bool
mu sync.Mutex
seen map[string]bool // channels found marked, in any dir, by this process
}
// NewDigestRatchet reads marks from own and also, and writes them to own.
func NewDigestRatchet(own string, also []string, require bool) *DigestRatchet {
r := &DigestRatchet{own: own, also: also, seen: map[string]bool{}}
r.require.Store(require)
return r
}
// markPath is a channel's mark: ///.digests, one
// flat file, with every "/" in the channel escaped as "%2F". A channel name
// never contains "%", so the encoding is collision-free: "canary" and
// "canary/x", or "foo" and "foo.digests/bar", each get a file of their own
// and never need the other's path to be a directory (Astra r1 #5).
func markPath(dir, acct, app, channel string) string {
return filepath.Join(dir, acct, app, strings.ReplaceAll(channel, "/", "%2F")+".digests")
}
// Admit is the ratchet's verdict on a verified pointer. A nil ratchet admits
// everything, which is today's behaviour for any caller that does not arm
// one. A pointer with digests marks its channel: in memory at once, so this
// process refuses a digestless replay from that moment, and on disk before
// the pointer is admitted. A mark that cannot be persisted refuses the
// pointer (ErrRatchetUnpersisted): installing or extracting on it would leave
// the box unprotected after a restart. A mark that cannot be read (anything
// but a confirmed absence) refuses a pointer without digests: only a box that
// knows it has never seen digests for the channel admits one (Astra r1 #1).
func (r *DigestRatchet) Admit(acct string, p *ChannelPointer) error {
if r == nil || p == nil {
return nil
}
if p.CarriesDigests() {
return r.note(acct, p.App, p.Channel)
}
if r.require.Load() {
return fmt.Errorf("%w: %s/%s/%s, and %s is on", ErrPointerWithoutDigests, acct, p.App, p.Channel, RequirePointerDigestEnv)
}
marked, err := r.marked(acct, p.App, p.Channel)
if err != nil {
return fmt.Errorf("%w: %s/%s/%s, and this box cannot tell whether it has already verified one with digests for that channel: %w",
ErrPointerWithoutDigests, acct, p.App, p.Channel, markUnreadable{err})
}
if marked {
return fmt.Errorf("%w: %s/%s/%s, and this box has already verified one with digests for that channel",
ErrPointerWithoutDigests, acct, p.App, p.Channel)
}
return nil
}
// marked reports whether any of the ratchet's directories holds the channel's
// mark. Only a confirmed absence (ENOENT, or ENOTDIR on a path component)
// counts as "not marked"; a mark must be a regular file, and any other answer
// (EACCES, EIO, a directory where the mark belongs) is an error, which the
// caller treats as fail-closed. A mark found in any directory wins over an
// error in another, since both refuse.
func (r *DigestRatchet) marked(acct, app, channel string) (bool, error) {
k := acct + "|" + app + "|" + channel
r.mu.Lock()
defer r.mu.Unlock()
if r.seen[k] {
return true, nil
}
var firstErr error
for _, dir := range append([]string{r.own}, r.also...) {
if dir == "" {
continue
}
path := markPath(dir, acct, app, channel)
fi, err := os.Stat(path)
switch {
case err == nil && fi.Mode().IsRegular():
r.seen[k] = true
return true, nil
case err == nil:
if firstErr == nil {
firstErr = fmt.Errorf("pointer digest mark %s is not a regular file", path)
}
case errors.Is(err, fs.ErrNotExist) || errors.Is(err, syscall.ENOTDIR):
// confirmed absent
default:
if firstErr == nil {
firstErr = err
}
}
}
return false, firstErr
}
func (r *DigestRatchet) note(acct, app, channel string) error {
k := acct + "|" + app + "|" + channel
r.mu.Lock()
defer r.mu.Unlock()
r.seen[k] = true // remembered at once, whatever the disk says
if r.own == "" {
return nil
}
path := markPath(r.own, acct, app, channel)
if fi, err := os.Stat(path); err == nil && fi.Mode().IsRegular() {
return nil
}
if err := safefile.MkdirAll(filepath.Dir(path), 0o755); err != nil {
log.Printf("release: pointer digest ratchet: %v", err)
return fmt.Errorf("%w: %v", ErrRatchetUnpersisted, err)
}
if err := WriteFileAtomic(path, []byte(time.Now().UTC().Format(time.RFC3339)+"\n"), 0o644); err != nil {
log.Printf("release: pointer digest ratchet: %v", err)
return fmt.Errorf("%w: %v", ErrRatchetUnpersisted, err)
}
return nil
}