diff --git a/metadata/updater/updater.go b/metadata/updater/updater.go index bd533b63..bed0168b 100644 --- a/metadata/updater/updater.go +++ b/metadata/updater/updater.go @@ -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) @@ -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 diff --git a/metadata/updater/updater_top_level_update_test.go b/metadata/updater/updater_top_level_update_test.go index 214105b4..8c05eecd 100644 --- a/metadata/updater/updater_top_level_update_test.go +++ b/metadata/updater/updater_top_level_update_test.go @@ -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) @@ -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)