Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion metadata/updater/updater.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,12 @@ func (update *Updater) onlineRefresh() error {
// The metadata on disk are verified against the provided root though,
// and expiration dates are verified.
func (update *Updater) unsafeLocalRefresh() error {
// Root is already loaded
// Load any rotated roots from disk
err := update.loadRootFromDisk()
if err != nil {
return err
}

// load timestamp
var p = filepath.Join(update.cfg.LocalMetadataDir, metadata.TIMESTAMP)
data, err := update.loadLocalMetadata(p)
Expand Down Expand Up @@ -511,6 +516,45 @@ func (update *Updater) loadRoot() error {
if err != nil {
return err
}
// also persist versioned root for offline use (e.g., unsafe local mode)
versionedRootName := fmt.Sprintf("%d.%s", nextVersion, metadata.ROOT)
err = update.persistMetadata(versionedRootName, data)
if err != nil {
return err
}
}
}
return nil
}

// loadRootFromDisk loads root metadata from local disk. Sequentially loads and
// verifies every newer root metadata version available on disk.
// This is used by unsafe local mode to support root rotation when operating offline.
func (update *Updater) loadRootFromDisk() error {
// calculate boundaries
lowerBound := update.trusted.Root.Signed.Version + 1
upperBound := lowerBound + update.cfg.MaxRootRotations

// loop until we find the latest available version of root on disk
for nextVersion := lowerBound; nextVersion < upperBound; nextVersion++ {
versionedRootName := fmt.Sprintf("%d.%s", nextVersion, metadata.ROOT)
rootPath := filepath.Join(update.cfg.LocalMetadataDir, fmt.Sprintf("%s.json", url.PathEscape(versionedRootName)))

// try to load versioned root from disk
data, err := os.ReadFile(rootPath)
if err != nil {
if os.IsNotExist(err) {
// no more root versions available on disk, stop the loop
break
}
// some other error occurred
return err
}

// verify and load the root metadata
_, err = update.trusted.UpdateRoot(data)
if err != nil {
return err
}
}
return nil
Expand Down
52 changes: 49 additions & 3 deletions metadata/updater/updater_top_level_update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -293,8 +293,8 @@ func TestUnsafeRefresh(t *testing.T) {
for _, role := range metadata.TOP_LEVEL_ROLE_NAMES {
var version int
if role == metadata.ROOT {
// The root file is written when the updater is
// created, so the version is reset.
// The root file on disk is version 1 (written when updater was created).
// Unsafe mode doesn't persist to disk, it only loads from disk.
version = 1
}
assertContentEquals(t, role, &version)
Expand All @@ -303,12 +303,58 @@ func TestUnsafeRefresh(t *testing.T) {
assert.Equal(t, metadata.ROOT, updater.trusted.Root.Signed.Type)
assert.Equal(t, metadata.SPECIFICATION_VERSION, updater.trusted.Root.Signed.SpecVersion)
assert.True(t, updater.trusted.Root.Signed.ConsistentSnapshot)
assert.Equal(t, int64(1), updater.trusted.Root.Signed.Version)
// However, the in-memory trusted root should be version 2 after loading from disk
assert.Equal(t, int64(2), updater.trusted.Root.Signed.Version)
assert.NotNil(t, updater.trusted.Snapshot)
assert.NotNil(t, updater.trusted.Timestamp)
assert.Equal(t, 1, len(updater.trusted.Targets))
}

func TestUnsafeRefreshWithRotatedRoots(t *testing.T) {
// First run a "real" refresh to establish initial metadata
err := loadOrResetTrustedRootMetadata()
assert.NoError(t, err)

updaterConfig, err := loadUpdaterConfig()
assert.NoError(t, err)
_, err = runRefresh(updaterConfig, time.Now())
assert.NoError(t, err)
assertFilesExist(t, metadata.TOP_LEVEL_ROLE_NAMES[:])

// Bump root version without changing keys (simpler test case)
// This tests if unsafe mode can handle multiple root versions on disk
simulator.Sim.MDRoot.Signed.Version = 2
simulator.Sim.PublishRoot()

simulator.Sim.MDRoot.Signed.Version = 3
simulator.Sim.PublishRoot()

// Run another refresh in online mode to download the new root versions
// This ensures root.2.json and root.3.json are in the local cache
updaterConfig, err = loadUpdaterConfig()
assert.NoError(t, err)
onlineUpdater, err := runRefresh(updaterConfig, time.Now())
assert.NoError(t, err)
assert.Equal(t, int64(3), onlineUpdater.trusted.Root.Signed.Version)

// Now create a new unsafe updater with the original trusted root (version 1)
// and verify it loads the rotated roots from disk
updaterConfig, err = loadUnsafeUpdaterConfig()
assert.NoError(t, err)
unsafeUpdater, err := runRefresh(updaterConfig, time.Now())
assert.NoError(t, err)

// The key assertion: unsafe mode should load rotated roots from disk
// and end up with the latest root version
assert.Equal(t, int64(3), unsafeUpdater.trusted.Root.Signed.Version,
"Unsafe local mode should load rotated roots from disk")

// Verify the other metadata is properly loaded
assert.NotNil(t, unsafeUpdater.trusted.Timestamp)
assert.NotNil(t, unsafeUpdater.trusted.Snapshot)
assert.Equal(t, 1, len(unsafeUpdater.trusted.Targets))
}

func TestTrustedRootMissing(t *testing.T) {
err := loadOrResetTrustedRootMetadata()
assert.NoError(t, err)
Expand Down