mirror of
https://github.com/tailscale/tailscale.git
synced 2026-02-09 09:41:49 +01:00
This file was never truly necessary and has never actually been used in the history of Tailscale's open source releases. A Brief History of AUTHORS files --- The AUTHORS file was a pattern developed at Google, originally for Chromium, then adopted by Go and a bunch of other projects. The problem was that Chromium originally had a copyright line only recognizing Google as the copyright holder. Because Google (and most open source projects) do not require copyright assignemnt for contributions, each contributor maintains their copyright. Some large corporate contributors then tried to add their own name to the copyright line in the LICENSE file or in file headers. This quickly becomes unwieldy, and puts a tremendous burden on anyone building on top of Chromium, since the license requires that they keep all copyright lines intact. The compromise was to create an AUTHORS file that would list all of the copyright holders. The LICENSE file and source file headers would then include that list by reference, listing the copyright holder as "The Chromium Authors". This also become cumbersome to simply keep the file up to date with a high rate of new contributors. Plus it's not always obvious who the copyright holder is. Sometimes it is the individual making the contribution, but many times it may be their employer. There is no way for the proejct maintainer to know. Eventually, Google changed their policy to no longer recommend trying to keep the AUTHORS file up to date proactively, and instead to only add to it when requested: https://opensource.google/docs/releasing/authors. They are also clear that: > Adding contributors to the AUTHORS file is entirely within the > project's discretion and has no implications for copyright ownership. It was primarily added to appease a small number of large contributors that insisted that they be recognized as copyright holders (which was entirely their right to do). But it's not truly necessary, and not even the most accurate way of identifying contributors and/or copyright holders. In practice, we've never added anyone to our AUTHORS file. It only lists Tailscale, so it's not really serving any purpose. It also causes confusion because Tailscalars put the "Tailscale Inc & AUTHORS" header in other open source repos which don't actually have an AUTHORS file, so it's ambiguous what that means. Instead, we just acknowledge that the contributors to Tailscale (whoever they are) are copyright holders for their individual contributions. We also have the benefit of using the DCO (developercertificate.org) which provides some additional certification of their right to make the contribution. The source file changes were purely mechanical with: git ls-files | xargs sed -i -e 's/\(Tailscale Inc &\) AUTHORS/\1 contributors/g' Updates #cleanup Change-Id: Ia101a4a3005adb9118051b3416f5a64a4a45987d Signed-off-by: Will Norris <will@tailscale.com>
978 lines
27 KiB
Go
978 lines
27 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
//go:build !ts_omit_tailnetlock
|
|
|
|
package tka
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"maps"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/fxamacker/cbor/v2"
|
|
"tailscale.com/atomicfile"
|
|
"tailscale.com/tstime"
|
|
"tailscale.com/util/testenv"
|
|
)
|
|
|
|
// Chonk implementations provide durable storage for AUMs and other
|
|
// TKA state.
|
|
//
|
|
// All methods must be thread-safe.
|
|
//
|
|
// The name 'tailchonk' was coined by @catzkorn.
|
|
type Chonk interface {
|
|
// AUM returns the AUM with the specified digest.
|
|
//
|
|
// If the AUM does not exist, then os.ErrNotExist is returned.
|
|
AUM(hash AUMHash) (AUM, error)
|
|
|
|
// ChildAUMs returns all AUMs with a specified previous
|
|
// AUM hash.
|
|
ChildAUMs(prevAUMHash AUMHash) ([]AUM, error)
|
|
|
|
// CommitVerifiedAUMs durably stores the provided AUMs.
|
|
// Callers MUST ONLY provide AUMs which are verified (specifically,
|
|
// a call to aumVerify() must return a nil error).
|
|
// as the implementation assumes that only verified AUMs are stored.
|
|
CommitVerifiedAUMs(updates []AUM) error
|
|
|
|
// Heads returns AUMs for which there are no children. In other
|
|
// words, the latest AUM in all possible chains (the 'leaves').
|
|
Heads() ([]AUM, error)
|
|
|
|
// SetLastActiveAncestor is called to record the oldest-known AUM
|
|
// that contributed to the current state. This value is used as
|
|
// a hint on next startup to determine which chain to pick when computing
|
|
// the current state, if there are multiple distinct chains.
|
|
SetLastActiveAncestor(hash AUMHash) error
|
|
|
|
// LastActiveAncestor returns the oldest-known AUM that was (in a
|
|
// previous run) an ancestor of the current state. This is used
|
|
// as a hint to pick the correct chain in the event that the Chonk stores
|
|
// multiple distinct chains.
|
|
LastActiveAncestor() (*AUMHash, error)
|
|
}
|
|
|
|
// CompactableChonk implementation are extensions of Chonk, which are
|
|
// able to be operated by compaction logic to deleted old AUMs.
|
|
type CompactableChonk interface {
|
|
Chonk
|
|
|
|
// AllAUMs returns all AUMs stored in the chonk.
|
|
AllAUMs() ([]AUMHash, error)
|
|
|
|
// CommitTime returns the time at which the AUM was committed.
|
|
//
|
|
// If the AUM does not exist, then os.ErrNotExist is returned.
|
|
CommitTime(hash AUMHash) (time.Time, error)
|
|
|
|
// PurgeAUMs permanently and irrevocably deletes the specified
|
|
// AUMs from storage.
|
|
PurgeAUMs(hashes []AUMHash) error
|
|
|
|
// RemoveAll permanently and completely clears the TKA state. This should
|
|
// be called when the user disables Tailnet Lock.
|
|
RemoveAll() error
|
|
}
|
|
|
|
// Mem implements in-memory storage of TKA state, suitable for
|
|
// tests or cases where filesystem storage is unavailable.
|
|
//
|
|
// Mem implements the Chonk interface.
|
|
//
|
|
// Mem is thread-safe.
|
|
type Mem struct {
|
|
mu sync.RWMutex
|
|
aums map[AUMHash]AUM
|
|
commitTimes map[AUMHash]time.Time
|
|
clock tstime.Clock
|
|
|
|
// parentIndex is a map of AUMs to the AUMs for which they are
|
|
// the parent.
|
|
//
|
|
// For example, if parent index is {1 -> {2, 3, 4}}, that means
|
|
// that AUMs 2, 3, 4 all have aum.PrevAUMHash = 1.
|
|
parentIndex map[AUMHash][]AUMHash
|
|
|
|
lastActiveAncestor *AUMHash
|
|
}
|
|
|
|
// ChonkMem returns an implementation of Chonk which stores TKA state
|
|
// in-memory.
|
|
func ChonkMem() *Mem {
|
|
return &Mem{
|
|
clock: tstime.DefaultClock{},
|
|
}
|
|
}
|
|
|
|
// SetClock sets the clock used by [Mem]. This is only for use in tests,
|
|
// and will panic if called from non-test code.
|
|
func (c *Mem) SetClock(clock tstime.Clock) {
|
|
if !testenv.InTest() {
|
|
panic("used SetClock in non-test code")
|
|
}
|
|
c.clock = clock
|
|
}
|
|
|
|
func (c *Mem) SetLastActiveAncestor(hash AUMHash) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.lastActiveAncestor = &hash
|
|
return nil
|
|
}
|
|
|
|
func (c *Mem) LastActiveAncestor() (*AUMHash, error) {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.lastActiveAncestor, nil
|
|
}
|
|
|
|
// Heads returns AUMs for which there are no children. In other
|
|
// words, the latest AUM in all chains (the 'leaf').
|
|
func (c *Mem) Heads() ([]AUM, error) {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
out := make([]AUM, 0, 6)
|
|
|
|
// An AUM is a 'head' if there are no nodes for which it is the parent.
|
|
for _, a := range c.aums {
|
|
if len(c.parentIndex[a.Hash()]) == 0 {
|
|
out = append(out, a)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// AUM returns the AUM with the specified digest.
|
|
func (c *Mem) AUM(hash AUMHash) (AUM, error) {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
aum, ok := c.aums[hash]
|
|
if !ok {
|
|
return AUM{}, os.ErrNotExist
|
|
}
|
|
return aum, nil
|
|
}
|
|
|
|
// ChildAUMs returns all AUMs with a specified previous
|
|
// AUM hash.
|
|
func (c *Mem) ChildAUMs(prevAUMHash AUMHash) ([]AUM, error) {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
out := make([]AUM, 0, 6)
|
|
for _, entry := range c.parentIndex[prevAUMHash] {
|
|
out = append(out, c.aums[entry])
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
// CommitVerifiedAUMs durably stores the provided AUMs.
|
|
// Callers MUST ONLY provide well-formed and verified AUMs,
|
|
// as the rest of the TKA implementation assumes that only
|
|
// verified AUMs are stored.
|
|
func (c *Mem) CommitVerifiedAUMs(updates []AUM) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.aums == nil {
|
|
c.parentIndex = make(map[AUMHash][]AUMHash, 64)
|
|
c.aums = make(map[AUMHash]AUM, 64)
|
|
c.commitTimes = make(map[AUMHash]time.Time, 64)
|
|
}
|
|
|
|
updateLoop:
|
|
for _, aum := range updates {
|
|
aumHash := aum.Hash()
|
|
c.aums[aumHash] = aum
|
|
c.commitTimes[aumHash] = c.now()
|
|
|
|
parent, ok := aum.Parent()
|
|
if ok {
|
|
for _, exists := range c.parentIndex[parent] {
|
|
if exists == aumHash {
|
|
continue updateLoop
|
|
}
|
|
}
|
|
c.parentIndex[parent] = append(c.parentIndex[parent], aumHash)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// now returns the current time, optionally using the overridden
|
|
// clock if set.
|
|
func (c *Mem) now() time.Time {
|
|
if c.clock == nil {
|
|
return time.Now()
|
|
} else {
|
|
return c.clock.Now()
|
|
}
|
|
}
|
|
|
|
// RemoveAll permanently and completely clears the TKA state.
|
|
func (c *Mem) RemoveAll() error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.aums = nil
|
|
c.commitTimes = nil
|
|
c.parentIndex = nil
|
|
c.lastActiveAncestor = nil
|
|
return nil
|
|
}
|
|
|
|
// AllAUMs returns all AUMs stored in the chonk.
|
|
func (c *Mem) AllAUMs() ([]AUMHash, error) {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
|
|
return slices.Collect(maps.Keys(c.aums)), nil
|
|
}
|
|
|
|
// CommitTime returns the time at which the AUM was committed.
|
|
//
|
|
// If the AUM does not exist, then os.ErrNotExist is returned.
|
|
func (c *Mem) CommitTime(h AUMHash) (time.Time, error) {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
|
|
t, ok := c.commitTimes[h]
|
|
if ok {
|
|
return t, nil
|
|
} else {
|
|
return time.Time{}, os.ErrNotExist
|
|
}
|
|
}
|
|
|
|
// PurgeAUMs marks the specified AUMs for deletion from storage.
|
|
func (c *Mem) PurgeAUMs(hashes []AUMHash) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
for _, h := range hashes {
|
|
// Remove the deleted AUM from the list of its parents' children.
|
|
//
|
|
// However, we leave the list of this AUM's children in parentIndex,
|
|
// so we can find them later in ChildAUMs().
|
|
if aum, ok := c.aums[h]; ok {
|
|
parent, hasParent := aum.Parent()
|
|
if hasParent {
|
|
c.parentIndex[parent] = slices.DeleteFunc(
|
|
c.parentIndex[parent],
|
|
func(other AUMHash) bool { return bytes.Equal(h[:], other[:]) },
|
|
)
|
|
if len(c.parentIndex[parent]) == 0 {
|
|
delete(c.parentIndex, parent)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Delete this AUM from the list of AUMs and commit times.
|
|
delete(c.aums, h)
|
|
delete(c.commitTimes, h)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// FS implements filesystem storage of TKA state.
|
|
//
|
|
// FS implements the Chonk interface.
|
|
type FS struct {
|
|
base string
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
// ChonkDir returns an implementation of Chonk which uses the
|
|
// given directory to store TKA state.
|
|
func ChonkDir(dir string) (*FS, error) {
|
|
if err := os.MkdirAll(dir, 0755); err != nil && !os.IsExist(err) {
|
|
return nil, fmt.Errorf("creating chonk root dir: %v", err)
|
|
}
|
|
|
|
stat, err := os.Stat(dir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !stat.IsDir() {
|
|
return nil, fmt.Errorf("chonk directory %q is a file", dir)
|
|
}
|
|
|
|
// TODO(tom): *FS marks AUMs as deleted but does not actually
|
|
// delete them, to avoid data loss in the event of a bug.
|
|
// Implement deletion after we are fairly sure in the implementation.
|
|
|
|
return &FS{base: dir}, nil
|
|
}
|
|
|
|
// fsHashInfo describes how information about an AUMHash is represented
|
|
// on disk.
|
|
//
|
|
// The CBOR-serialization of this struct is stored to base/__/base32(hash)
|
|
// where __ are the first two characters of base32(hash).
|
|
//
|
|
// CBOR was chosen because we are already using it and it serializes
|
|
// much smaller than JSON for AUMs. The 'keyasint' thing isn't essential
|
|
// but again it saves a bunch of bytes.
|
|
//
|
|
// We have removed the following fields from fsHashInfo, but they may be
|
|
// present in data stored in existing deployments. Do not reuse these values,
|
|
// to avoid getting unexpected values from legacy data:
|
|
// - cbor:1, Children
|
|
type fsHashInfo struct {
|
|
AUM *AUM `cbor:"2,keyasint"`
|
|
CreatedUnix int64 `cbor:"3,keyasint,omitempty"`
|
|
|
|
// PurgedUnix is set when the AUM is deleted. The value is
|
|
// the unix epoch at the time it was deleted.
|
|
//
|
|
// While a non-zero PurgedUnix symbolizes the AUM is deleted,
|
|
// the fsHashInfo entry can continue to exist to track children
|
|
// of this AUMHash.
|
|
PurgedUnix int64 `cbor:"4,keyasint,omitempty"`
|
|
}
|
|
|
|
// aumDir returns the directory an AUM is stored in, and its filename
|
|
// within the directory.
|
|
func (c *FS) aumDir(h AUMHash) (dir, base string) {
|
|
s := h.String()
|
|
return filepath.Join(c.base, s[:2]), s
|
|
}
|
|
|
|
// AUM returns the AUM with the specified digest.
|
|
//
|
|
// If the AUM does not exist, then os.ErrNotExist is returned.
|
|
func (c *FS) AUM(hash AUMHash) (AUM, error) {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
|
|
info, err := c.get(hash)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return AUM{}, os.ErrNotExist
|
|
}
|
|
return AUM{}, err
|
|
}
|
|
if info.AUM == nil || info.PurgedUnix > 0 {
|
|
return AUM{}, os.ErrNotExist
|
|
}
|
|
return *info.AUM, nil
|
|
}
|
|
|
|
// CommitTime returns the time at which the AUM was committed.
|
|
//
|
|
// If the AUM does not exist, then os.ErrNotExist is returned.
|
|
func (c *FS) CommitTime(h AUMHash) (time.Time, error) {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
|
|
info, err := c.get(h)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return time.Time{}, os.ErrNotExist
|
|
}
|
|
return time.Time{}, err
|
|
}
|
|
if info.PurgedUnix > 0 {
|
|
return time.Time{}, os.ErrNotExist
|
|
}
|
|
if info.CreatedUnix > 0 {
|
|
return time.Unix(info.CreatedUnix, 0), nil
|
|
}
|
|
|
|
// If we got this far, the AUM exists but CreatedUnix is not
|
|
// set, presumably because this AUM was committed using a version
|
|
// of tailscaled that pre-dates the introduction of CreatedUnix.
|
|
// As such, we use the file modification time as a suitable analog.
|
|
dir, base := c.aumDir(h)
|
|
s, err := os.Stat(filepath.Join(dir, base))
|
|
if err != nil {
|
|
return time.Time{}, nil
|
|
}
|
|
return s.ModTime(), nil
|
|
}
|
|
|
|
// AUM returns any known AUMs with a specific parent hash.
|
|
func (c *FS) ChildAUMs(prevAUMHash AUMHash) ([]AUM, error) {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
|
|
var out []AUM
|
|
|
|
err := c.scanHashes(func(info *fsHashInfo) {
|
|
if info.AUM != nil && bytes.Equal(info.AUM.PrevAUMHash, prevAUMHash[:]) {
|
|
out = append(out, *info.AUM)
|
|
}
|
|
})
|
|
|
|
return out, err
|
|
}
|
|
|
|
func (c *FS) get(h AUMHash) (*fsHashInfo, error) {
|
|
dir, base := c.aumDir(h)
|
|
f, err := os.Open(filepath.Join(dir, base))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
|
|
m, err := cborDecOpts.DecMode()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var out fsHashInfo
|
|
if err := m.NewDecoder(f).Decode(&out); err != nil {
|
|
return nil, err
|
|
}
|
|
if out.AUM != nil && out.AUM.Hash() != h {
|
|
return nil, fmt.Errorf("%s: AUM does not match file name hash %s", f.Name(), out.AUM.Hash())
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// Heads returns AUMs for which there are no children. In other
|
|
// words, the latest AUM in all possible chains (the 'leaves').
|
|
//
|
|
// Heads is expected to be called infrequently compared to AUM() or
|
|
// ChildAUMs(), so we haven't put any work into maintaining an index.
|
|
// Instead, the full set of AUMs is scanned.
|
|
func (c *FS) Heads() ([]AUM, error) {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
|
|
// Scan the complete list of AUMs, and build a list of all parent hashes.
|
|
// This tells us which AUMs have children.
|
|
var parentHashes []AUMHash
|
|
|
|
allAUMs, err := c.AllAUMs()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, h := range allAUMs {
|
|
aum, err := c.AUM(h)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
parent, hasParent := aum.Parent()
|
|
if !hasParent {
|
|
continue
|
|
}
|
|
if !slices.Contains(parentHashes, parent) {
|
|
parentHashes = append(parentHashes, parent)
|
|
}
|
|
}
|
|
|
|
// Now scan a second time, and only include AUMs which weren't marked as
|
|
// the parent of any other AUM.
|
|
out := make([]AUM, 0, 6) // 6 is arbitrary.
|
|
|
|
for _, h := range allAUMs {
|
|
if slices.Contains(parentHashes, h) {
|
|
continue
|
|
}
|
|
aum, err := c.AUM(h)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, aum)
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
// RemoveAll permanently and completely clears the TKA state.
|
|
func (c *FS) RemoveAll() error {
|
|
return os.RemoveAll(c.base)
|
|
}
|
|
|
|
// AllAUMs returns all AUMs stored in the chonk.
|
|
func (c *FS) AllAUMs() ([]AUMHash, error) {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
|
|
out := make([]AUMHash, 0, 6) // 6 is arbitrary.
|
|
err := c.scanHashes(func(info *fsHashInfo) {
|
|
if info.AUM != nil {
|
|
out = append(out, info.AUM.Hash())
|
|
}
|
|
})
|
|
return out, err
|
|
}
|
|
|
|
func (c *FS) scanHashes(eachHashInfo func(*fsHashInfo)) error {
|
|
prefixDirs, err := os.ReadDir(c.base)
|
|
if err != nil {
|
|
return fmt.Errorf("reading prefix dirs: %v", err)
|
|
}
|
|
for _, prefix := range prefixDirs {
|
|
if !prefix.IsDir() {
|
|
continue
|
|
}
|
|
files, err := os.ReadDir(filepath.Join(c.base, prefix.Name()))
|
|
if err != nil {
|
|
return fmt.Errorf("reading prefix dir: %v", err)
|
|
}
|
|
for _, file := range files {
|
|
// Ignore files whose names aren't valid AUM hashes, which may be
|
|
// temporary files which are partway through being written, or other
|
|
// files added by the OS (like .DS_Store) which we can ignore.
|
|
// TODO(alexc): it might be useful to append a suffix like `.aum` to
|
|
// filenames, so we can more easily distinguish between AUMs and
|
|
// arbitrary other files.
|
|
var h AUMHash
|
|
if err := h.UnmarshalText([]byte(file.Name())); err != nil {
|
|
log.Printf("ignoring unexpected non-AUM: %s: %v", file.Name(), err)
|
|
continue
|
|
}
|
|
info, err := c.get(h)
|
|
if err != nil {
|
|
return fmt.Errorf("reading %x: %v", h, err)
|
|
}
|
|
if info.PurgedUnix > 0 {
|
|
continue
|
|
}
|
|
|
|
eachHashInfo(info)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SetLastActiveAncestor is called to record the oldest-known AUM
|
|
// that contributed to the current state. This value is used as
|
|
// a hint on next startup to determine which chain to pick when computing
|
|
// the current state, if there are multiple distinct chains.
|
|
func (c *FS) SetLastActiveAncestor(hash AUMHash) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return atomicfile.WriteFile(filepath.Join(c.base, "last_active_ancestor"), hash[:], 0644)
|
|
}
|
|
|
|
// LastActiveAncestor returns the oldest-known AUM that was (in a
|
|
// previous run) an ancestor of the current state. This is used
|
|
// as a hint to pick the correct chain in the event that the Chonk stores
|
|
// multiple distinct chains.
|
|
//
|
|
// Nil is returned if no last-active ancestor is set.
|
|
func (c *FS) LastActiveAncestor() (*AUMHash, error) {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
|
|
hash, err := os.ReadFile(filepath.Join(c.base, "last_active_ancestor"))
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, nil // Not exist == none set.
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
var out AUMHash
|
|
if len(hash) != len(out) {
|
|
return nil, fmt.Errorf("stored hash is of wrong length: %d != %d", len(hash), len(out))
|
|
}
|
|
copy(out[:], hash)
|
|
return &out, nil
|
|
}
|
|
|
|
// CommitVerifiedAUMs durably stores the provided AUMs.
|
|
// Callers MUST ONLY provide AUMs which are verified (specifically,
|
|
// a call to aumVerify must return a nil error), as the
|
|
// implementation assumes that only verified AUMs are stored.
|
|
func (c *FS) CommitVerifiedAUMs(updates []AUM) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
for i, aum := range updates {
|
|
h := aum.Hash()
|
|
err := c.commit(h, func(info *fsHashInfo) {
|
|
info.PurgedUnix = 0 // just in-case it was set for some reason
|
|
info.AUM = &aum
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("committing update[%d] (%x): %v", i, h, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// PurgeAUMs marks the specified AUMs for deletion from storage.
|
|
func (c *FS) PurgeAUMs(hashes []AUMHash) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
now := time.Now()
|
|
for i, h := range hashes {
|
|
stored, err := c.get(h)
|
|
if err != nil {
|
|
return fmt.Errorf("reading %d (%x): %w", i, h, err)
|
|
}
|
|
if stored.AUM == nil || stored.PurgedUnix > 0 {
|
|
continue
|
|
}
|
|
|
|
err = c.commit(h, func(info *fsHashInfo) {
|
|
info.PurgedUnix = now.Unix()
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("committing purge[%d] (%x): %w", i, h, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// commit calls the provided updater function to record changes relevant
|
|
// to the given hash. The caller is expected to update the AUM and
|
|
// Children fields, as relevant.
|
|
func (c *FS) commit(h AUMHash, updater func(*fsHashInfo)) error {
|
|
toCommit := fsHashInfo{}
|
|
|
|
existing, err := c.get(h)
|
|
switch {
|
|
case os.IsNotExist(err):
|
|
toCommit.CreatedUnix = time.Now().Unix()
|
|
case err != nil:
|
|
return err
|
|
default:
|
|
toCommit = *existing
|
|
}
|
|
|
|
updater(&toCommit)
|
|
if toCommit.AUM != nil && toCommit.AUM.Hash() != h {
|
|
return fmt.Errorf("cannot commit AUM with hash %x to %x", toCommit.AUM.Hash(), h)
|
|
}
|
|
|
|
dir, base := c.aumDir(h)
|
|
if err := os.MkdirAll(dir, 0755); err != nil && !os.IsExist(err) {
|
|
return fmt.Errorf("creating directory: %v", err)
|
|
}
|
|
|
|
m, err := cbor.CTAP2EncOptions().EncMode()
|
|
if err != nil {
|
|
return fmt.Errorf("cbor EncMode: %v", err)
|
|
}
|
|
|
|
var buff bytes.Buffer
|
|
if err := m.NewEncoder(&buff).Encode(toCommit); err != nil {
|
|
return fmt.Errorf("encoding: %v", err)
|
|
}
|
|
return atomicfile.WriteFile(filepath.Join(dir, base), buff.Bytes(), 0644)
|
|
}
|
|
|
|
// CompactionOptions describes tuneables to use when compacting a Chonk.
|
|
type CompactionOptions struct {
|
|
// The minimum number of ancestor AUMs to remember. The actual length
|
|
// of the chain post-compaction may be longer to reach a Checkpoint AUM.
|
|
MinChain int
|
|
// The minimum duration to store an AUM before it is a candidate for deletion.
|
|
MinAge time.Duration
|
|
}
|
|
|
|
// retainState tracks the state of an AUM hash as it is being considered for
|
|
// deletion.
|
|
type retainState uint8
|
|
|
|
// Valid retainState flags.
|
|
const (
|
|
retainStateActive retainState = 1 << iota // The AUM is part of the active chain and less than MinChain hops from HEAD.
|
|
retainStateYoung // The AUM is younger than MinAge.
|
|
retainStateLeaf // The AUM is a descendant of an AUM to be retained.
|
|
retainStateAncestor // The AUM is part of a chain between a retained AUM and the new lastActiveAncestor.
|
|
retainStateCandidate // The AUM is part of the active chain.
|
|
|
|
// retainAUMMask is a bit mask of any bit which should prevent
|
|
// the deletion of an AUM.
|
|
retainAUMMask retainState = retainStateActive | retainStateYoung | retainStateLeaf | retainStateAncestor
|
|
)
|
|
|
|
// markActiveChain marks AUMs in the active chain.
|
|
// All AUMs that are within minChain ancestors of head, or are marked as young, are
|
|
// marked retainStateActive, and all remaining ancestors are
|
|
// marked retainStateCandidate.
|
|
//
|
|
// markActiveChain returns the next ancestor AUM which is a checkpoint AUM.
|
|
func markActiveChain(storage Chonk, verdict map[AUMHash]retainState, minChain int, head AUMHash) (lastActiveAncestor AUMHash, err error) {
|
|
next, err := storage.AUM(head)
|
|
if err != nil {
|
|
return AUMHash{}, err
|
|
}
|
|
|
|
for i := range minChain {
|
|
h := next.Hash()
|
|
verdict[h] |= retainStateActive
|
|
|
|
parent, hasParent := next.Parent()
|
|
if !hasParent {
|
|
// Genesis AUM (beginning of time). The chain isnt long enough to need truncating.
|
|
return h, nil
|
|
}
|
|
|
|
if next, err = storage.AUM(parent); err != nil {
|
|
if err == os.ErrNotExist {
|
|
// We've reached the end of the chain we have stored.
|
|
return h, nil
|
|
}
|
|
return AUMHash{}, fmt.Errorf("reading active chain (retainStateActive) (%d, %v): %w", i, parent, err)
|
|
}
|
|
}
|
|
|
|
// If we got this far, we have at least minChain AUMs stored, and minChain number
|
|
// of ancestors have been marked for retention. We now continue to iterate backwards
|
|
// till we find an AUM which we can compact to: either a Checkpoint AUM which is old
|
|
// enough, or the genesis AUM.
|
|
for {
|
|
h := next.Hash()
|
|
verdict[h] |= retainStateActive
|
|
|
|
parent, hasParent := next.Parent()
|
|
isYoung := verdict[h]&retainStateYoung != 0
|
|
|
|
if next.MessageKind == AUMCheckpoint {
|
|
lastActiveAncestor = h
|
|
if !isYoung || !hasParent {
|
|
break
|
|
}
|
|
}
|
|
|
|
if next, err = storage.AUM(parent); err != nil {
|
|
return AUMHash{}, fmt.Errorf("searching for compaction target (%v): %w", parent, err)
|
|
}
|
|
}
|
|
|
|
// Mark remaining known ancestors as retainStateCandidate.
|
|
for {
|
|
parent, hasParent := next.Parent()
|
|
if !hasParent {
|
|
break
|
|
}
|
|
verdict[parent] |= retainStateCandidate
|
|
if next, err = storage.AUM(parent); err != nil {
|
|
if err == os.ErrNotExist {
|
|
// We've reached the end of the chain we have stored.
|
|
break
|
|
}
|
|
return AUMHash{}, fmt.Errorf("reading active chain (retainStateCandidate, %v): %w", parent, err)
|
|
}
|
|
}
|
|
|
|
return lastActiveAncestor, nil
|
|
}
|
|
|
|
// markYoungAUMs marks all AUMs younger than minAge for retention. All
|
|
// candidate AUMs must exist in verdict.
|
|
func markYoungAUMs(storage CompactableChonk, verdict map[AUMHash]retainState, minAge time.Duration) error {
|
|
minTime := time.Now().Add(-minAge)
|
|
for h := range verdict {
|
|
commitTime, err := storage.CommitTime(h)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if commitTime.After(minTime) {
|
|
verdict[h] |= retainStateYoung
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// markAncestorIntersectionAUMs walks backwards from all AUMs to be retained,
|
|
// ensuring they intersect with candidateAncestor. All AUMs between a retained
|
|
// AUM and candidateAncestor are marked for retention.
|
|
//
|
|
// If there is no intersection between candidateAncestor and the ancestors of
|
|
// a retained AUM (this can happen if a retained AUM intersects the main chain
|
|
// before candidateAncestor) then candidate ancestor is recomputed based on
|
|
// the new oldest intersection.
|
|
//
|
|
// The final value for lastActiveAncestor is returned.
|
|
func markAncestorIntersectionAUMs(storage Chonk, verdict map[AUMHash]retainState, candidateAncestor AUMHash) (lastActiveAncestor AUMHash, err error) {
|
|
toScan := make([]AUMHash, 0, len(verdict))
|
|
for h, v := range verdict {
|
|
if (v & retainAUMMask) == 0 {
|
|
continue // not marked for retention, so don't need to consider it
|
|
}
|
|
if h == candidateAncestor {
|
|
continue
|
|
}
|
|
toScan = append(toScan, h)
|
|
}
|
|
|
|
var didAdjustCandidateAncestor bool
|
|
for len(toScan) > 0 {
|
|
nextIterScan := make([]AUMHash, 0, len(verdict))
|
|
for _, h := range toScan {
|
|
if verdict[h]&retainStateAncestor != 0 {
|
|
// This AUM and its ancestors have already been iterated.
|
|
continue
|
|
}
|
|
verdict[h] |= retainStateAncestor
|
|
|
|
a, err := storage.AUM(h)
|
|
if err != nil {
|
|
return AUMHash{}, fmt.Errorf("reading %v: %w", h, err)
|
|
}
|
|
parent, hasParent := a.Parent()
|
|
if !hasParent {
|
|
return AUMHash{}, errors.New("reached genesis AUM without intersecting with candidate ancestor")
|
|
}
|
|
|
|
if verdict[parent]&retainAUMMask != 0 {
|
|
// Includes candidateAncestor (has retainStateActive set)
|
|
continue
|
|
}
|
|
if verdict[parent]&retainStateCandidate != 0 {
|
|
// We've intersected with the active chain but haven't done so through
|
|
// candidateAncestor. That means that we intersect the active chain
|
|
// before candidateAncestor, hence candidateAncestor actually needs
|
|
// to be earlier than it is now.
|
|
candidateAncestor = parent
|
|
didAdjustCandidateAncestor = true
|
|
verdict[parent] |= retainStateAncestor
|
|
|
|
// There could be AUMs on the active chain between our new candidateAncestor
|
|
// and the old one, make sure they are marked as retained.
|
|
next := parent
|
|
childLoop:
|
|
for {
|
|
children, err := storage.ChildAUMs(next)
|
|
if err != nil {
|
|
return AUMHash{}, fmt.Errorf("reading children %v: %w", next, err)
|
|
}
|
|
// While there can be many children of an AUM, there can only be
|
|
// one child on the active chain (it will have retainStateCandidate set).
|
|
for _, a := range children {
|
|
h := a.Hash()
|
|
if v := verdict[h]; v&retainStateCandidate != 0 && v&retainStateActive == 0 {
|
|
verdict[h] |= retainStateAncestor
|
|
next = h
|
|
continue childLoop
|
|
}
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
nextIterScan = append(nextIterScan, parent)
|
|
}
|
|
toScan = nextIterScan
|
|
}
|
|
|
|
// If candidateAncestor was adjusted backwards, then it may not be a checkpoint
|
|
// (and hence a valid compaction candidate). If so, iterate backwards and adjust
|
|
// the candidateAncestor till we find a checkpoint.
|
|
if didAdjustCandidateAncestor {
|
|
var next AUM
|
|
if next, err = storage.AUM(candidateAncestor); err != nil {
|
|
return AUMHash{}, fmt.Errorf("searching for compaction target (%v): %w", candidateAncestor, err)
|
|
}
|
|
|
|
for {
|
|
h := next.Hash()
|
|
verdict[h] |= retainStateActive
|
|
if next.MessageKind == AUMCheckpoint {
|
|
candidateAncestor = h
|
|
break
|
|
}
|
|
|
|
parent, hasParent := next.Parent()
|
|
if !hasParent {
|
|
return AUMHash{}, errors.New("reached genesis AUM without finding an appropriate candidateAncestor")
|
|
}
|
|
if next, err = storage.AUM(parent); err != nil {
|
|
return AUMHash{}, fmt.Errorf("searching for compaction target (%v): %w", parent, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return candidateAncestor, nil
|
|
}
|
|
|
|
// markDescendantAUMs marks all children of a retained AUM as retained.
|
|
func markDescendantAUMs(storage Chonk, verdict map[AUMHash]retainState) error {
|
|
toScan := make([]AUMHash, 0, len(verdict))
|
|
for h, v := range verdict {
|
|
if v&retainAUMMask == 0 {
|
|
continue // not marked, so don't need to mark descendants
|
|
}
|
|
toScan = append(toScan, h)
|
|
}
|
|
|
|
for len(toScan) > 0 {
|
|
nextIterScan := make([]AUMHash, 0, len(verdict))
|
|
for _, h := range toScan {
|
|
if verdict[h]&retainStateLeaf != 0 {
|
|
// This AUM and its descendants have already been marked.
|
|
continue
|
|
}
|
|
verdict[h] |= retainStateLeaf
|
|
|
|
children, err := storage.ChildAUMs(h)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, a := range children {
|
|
nextIterScan = append(nextIterScan, a.Hash())
|
|
}
|
|
}
|
|
toScan = nextIterScan
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Compact deletes old AUMs from storage, based on the parameters given in opts.
|
|
func Compact(storage CompactableChonk, head AUMHash, opts CompactionOptions) (lastActiveAncestor AUMHash, err error) {
|
|
if opts.MinChain == 0 {
|
|
return AUMHash{}, errors.New("opts.MinChain must be set")
|
|
}
|
|
if opts.MinAge == 0 {
|
|
return AUMHash{}, errors.New("opts.MinAge must be set")
|
|
}
|
|
|
|
all, err := storage.AllAUMs()
|
|
if err != nil {
|
|
return AUMHash{}, fmt.Errorf("AllAUMs: %w", err)
|
|
}
|
|
verdict := make(map[AUMHash]retainState, len(all))
|
|
for _, h := range all {
|
|
verdict[h] = 0
|
|
}
|
|
|
|
if err := markYoungAUMs(storage, verdict, opts.MinAge); err != nil {
|
|
return AUMHash{}, fmt.Errorf("marking young AUMs: %w", err)
|
|
}
|
|
if lastActiveAncestor, err = markActiveChain(storage, verdict, opts.MinChain, head); err != nil {
|
|
return AUMHash{}, fmt.Errorf("marking active chain: %w", err)
|
|
}
|
|
if err := markDescendantAUMs(storage, verdict); err != nil {
|
|
return AUMHash{}, fmt.Errorf("marking descendant AUMs: %w", err)
|
|
}
|
|
if lastActiveAncestor, err = markAncestorIntersectionAUMs(storage, verdict, lastActiveAncestor); err != nil {
|
|
return AUMHash{}, fmt.Errorf("marking ancestor intersection: %w", err)
|
|
}
|
|
|
|
toDelete := make([]AUMHash, 0, len(verdict))
|
|
for h, v := range verdict {
|
|
if v&retainAUMMask == 0 { // no retention set
|
|
toDelete = append(toDelete, h)
|
|
}
|
|
}
|
|
|
|
if err := storage.SetLastActiveAncestor(lastActiveAncestor); err != nil {
|
|
return AUMHash{}, err
|
|
}
|
|
return lastActiveAncestor, storage.PurgeAUMs(toDelete)
|
|
}
|