| package client |
| |
| import ( |
| "bytes" |
| "encoding/json" |
| "errors" |
| "io" |
| "io/ioutil" |
| |
| "github.com/flynn/go-tuf/data" |
| "github.com/flynn/go-tuf/util" |
| "github.com/flynn/go-tuf/verify" |
| ) |
| |
| const ( |
| // This is the upper limit in bytes we will use to limit the download |
| // size of the root/timestamp roles, since we might not don't know how |
| // big it is. |
| defaultRootDownloadLimit = 512000 |
| defaultTimestampDownloadLimit = 16384 |
| ) |
| |
| // LocalStore is local storage for downloaded top-level metadata. |
| type LocalStore interface { |
| // GetMeta returns top-level metadata from local storage. The keys are |
| // in the form `ROLE.json`, with ROLE being a valid top-level role. |
| GetMeta() (map[string]json.RawMessage, error) |
| |
| // SetMeta persists the given top-level metadata in local storage, the |
| // name taking the same format as the keys returned by GetMeta. |
| SetMeta(name string, meta json.RawMessage) error |
| } |
| |
| // RemoteStore downloads top-level metadata and target files from a remote |
| // repository. |
| type RemoteStore interface { |
| // GetMeta downloads the given metadata from remote storage. |
| // |
| // `name` is the filename of the metadata (e.g. "root.json") |
| // |
| // `err` is ErrNotFound if the given file does not exist. |
| // |
| // `size` is the size of the stream, -1 indicating an unknown length. |
| GetMeta(name string) (stream io.ReadCloser, size int64, err error) |
| |
| // GetTarget downloads the given target file from remote storage. |
| // |
| // `path` is the path of the file relative to the root of the remote |
| // targets directory (e.g. "/path/to/file.txt"). |
| // |
| // `err` is ErrNotFound if the given file does not exist. |
| // |
| // `size` is the size of the stream, -1 indicating an unknown length. |
| GetTarget(path string) (stream io.ReadCloser, size int64, err error) |
| } |
| |
| // Client provides methods for fetching updates from a remote repository and |
| // downloading remote target files. |
| type Client struct { |
| local LocalStore |
| remote RemoteStore |
| |
| // The following four fields represent the versions of metatdata either |
| // from local storage or from recently downloaded metadata |
| rootVer int |
| targetsVer int |
| snapshotVer int |
| timestampVer int |
| |
| // targets is the list of available targets, either from local storage |
| // or from recently downloaded targets metadata |
| targets data.TargetFiles |
| |
| // localMeta is the raw metadata from local storage and is used to |
| // check whether remote metadata is present locally |
| localMeta map[string]json.RawMessage |
| |
| // db is a key DB used for verifying metadata |
| db *verify.DB |
| |
| // consistentSnapshot indicates whether the remote storage is using |
| // consistent snapshots (as specified in root.json) |
| consistentSnapshot bool |
| } |
| |
| func NewClient(local LocalStore, remote RemoteStore) *Client { |
| return &Client{ |
| local: local, |
| remote: remote, |
| } |
| } |
| |
| // Init initializes a local repository. |
| // |
| // The latest root.json is fetched from remote storage, verified using rootKeys |
| // and threshold, and then saved in local storage. It is expected that rootKeys |
| // were securely distributed with the software being updated. |
| func (c *Client) Init(rootKeys []*data.Key, threshold int) error { |
| if len(rootKeys) < threshold { |
| return ErrInsufficientKeys |
| } |
| rootJSON, err := c.downloadMetaUnsafe("root.json", defaultRootDownloadLimit) |
| if err != nil { |
| return err |
| } |
| |
| c.db = verify.NewDB() |
| rootKeyIDs := make([]string, 0, len(rootKeys)) |
| for _, key := range rootKeys { |
| for _, id := range key.IDs() { |
| rootKeyIDs = append(rootKeyIDs, id) |
| if err := c.db.AddKey(id, key); err != nil { |
| return err |
| } |
| } |
| } |
| role := &data.Role{Threshold: threshold, KeyIDs: rootKeyIDs} |
| if err := c.db.AddRole("root", role); err != nil { |
| return err |
| } |
| |
| if err := c.decodeRoot(rootJSON); err != nil { |
| return err |
| } |
| |
| return c.local.SetMeta("root.json", rootJSON) |
| } |
| |
| // Update downloads and verifies remote metadata and returns updated targets. |
| // |
| // It performs the update part of "The client application" workflow from |
| // section 5.1 of the TUF spec: |
| // |
| // https://github.com/theupdateframework/tuf/blob/v0.9.9/docs/tuf-spec.txt#L714 |
| func (c *Client) Update() (data.TargetFiles, error) { |
| return c.update(false) |
| } |
| |
| func (c *Client) update(latestRoot bool) (data.TargetFiles, error) { |
| // Always start the update using local metadata |
| if err := c.getLocalMeta(); err != nil { |
| if _, ok := err.(verify.ErrExpired); ok { |
| if !latestRoot { |
| return c.updateWithLatestRoot(nil) |
| } |
| // this should not be reached as if the latest root has |
| // been downloaded and it is expired, updateWithLatestRoot |
| // should not have continued the update |
| return nil, err |
| } |
| if latestRoot && isErrRoleThreshold(err) { |
| // Root was updated with new keys, so our local metadata is no |
| // longer validating. Read only the versions from the local metadata |
| // and re-download everything. |
| if err := c.getRootAndLocalVersionsUnsafe(); err != nil { |
| return nil, err |
| } |
| } else { |
| return nil, err |
| } |
| } |
| |
| // Get timestamp.json, extract snapshot.json file meta and save the |
| // timestamp.json locally |
| timestampJSON, err := c.downloadMetaUnsafe("timestamp.json", defaultTimestampDownloadLimit) |
| if err != nil { |
| return nil, err |
| } |
| snapshotMeta, err := c.decodeTimestamp(timestampJSON) |
| if err != nil { |
| // ErrRoleThreshold could indicate timestamp keys have been |
| // revoked, so retry with the latest root.json |
| if isDecodeFailedWithErrRoleThreshold(err) && !latestRoot { |
| return c.updateWithLatestRoot(nil) |
| } |
| return nil, err |
| } |
| if err := c.local.SetMeta("timestamp.json", timestampJSON); err != nil { |
| return nil, err |
| } |
| |
| // Return ErrLatestSnapshot if we already have the latest snapshot.json |
| if c.hasMetaFromTimestamp("snapshot.json", snapshotMeta) { |
| return nil, ErrLatestSnapshot{c.snapshotVer} |
| } |
| |
| // Get snapshot.json, then extract root.json and targets.json file meta. |
| // |
| // The snapshot.json is only saved locally after checking root.json and |
| // targets.json so that it will be re-downloaded on subsequent updates |
| // if this update fails. |
| snapshotJSON, err := c.downloadMetaFromTimestamp("snapshot.json", snapshotMeta) |
| if err != nil { |
| return nil, err |
| } |
| rootMeta, targetsMeta, err := c.decodeSnapshot(snapshotJSON) |
| if err != nil { |
| // ErrRoleThreshold could indicate snapshot keys have been |
| // revoked, so retry with the latest root.json |
| if isDecodeFailedWithErrRoleThreshold(err) && !latestRoot { |
| return c.updateWithLatestRoot(nil) |
| } |
| return nil, err |
| } |
| |
| // If we don't have the root.json, download it, save it in local |
| // storage and restart the update |
| if !c.hasMetaFromSnapshot("root.json", rootMeta) { |
| return c.updateWithLatestRoot(&rootMeta) |
| } |
| |
| // If we don't have the targets.json, download it, determine updated |
| // targets and save targets.json in local storage |
| var updatedTargets data.TargetFiles |
| if !c.hasMetaFromSnapshot("targets.json", targetsMeta) { |
| targetsJSON, err := c.downloadMetaFromSnapshot("targets.json", targetsMeta) |
| if err != nil { |
| return nil, err |
| } |
| updatedTargets, err = c.decodeTargets(targetsJSON) |
| if err != nil { |
| return nil, err |
| } |
| if err := c.local.SetMeta("targets.json", targetsJSON); err != nil { |
| return nil, err |
| } |
| } |
| |
| // Save the snapshot.json now it has been processed successfully |
| if err := c.local.SetMeta("snapshot.json", snapshotJSON); err != nil { |
| return nil, err |
| } |
| |
| return updatedTargets, nil |
| } |
| |
| func (c *Client) updateWithLatestRoot(m *data.SnapshotFileMeta) (data.TargetFiles, error) { |
| var rootJSON json.RawMessage |
| var err error |
| if m == nil { |
| rootJSON, err = c.downloadMetaUnsafe("root.json", defaultRootDownloadLimit) |
| } else { |
| rootJSON, err = c.downloadMetaFromSnapshot("root.json", *m) |
| } |
| if err != nil { |
| return nil, err |
| } |
| if err := c.decodeRoot(rootJSON); err != nil { |
| return nil, err |
| } |
| if err := c.local.SetMeta("root.json", rootJSON); err != nil { |
| return nil, err |
| } |
| return c.update(true) |
| } |
| |
| // getLocalMeta decodes and verifies metadata from local storage. |
| // |
| // The verification of local files is purely for consistency, if an attacker |
| // has compromised the local storage, there is no guarantee it can be trusted. |
| func (c *Client) getLocalMeta() error { |
| meta, err := c.local.GetMeta() |
| if err != nil { |
| return err |
| } |
| |
| if rootJSON, ok := meta["root.json"]; ok { |
| // unmarshal root.json without verifying as we need the root |
| // keys first |
| s := &data.Signed{} |
| if err := json.Unmarshal(rootJSON, s); err != nil { |
| return err |
| } |
| root := &data.Root{} |
| if err := json.Unmarshal(s.Signed, root); err != nil { |
| return err |
| } |
| c.db = verify.NewDB() |
| for id, k := range root.Keys { |
| if err := c.db.AddKey(id, k); err != nil { |
| // FIXME(TUF-0.9) Ignore unknown keyids, which |
| // can happen during the transition to TUF-1.0. |
| if _, ok := err.(verify.ErrWrongID); !ok { |
| return err |
| } |
| } |
| } |
| for name, role := range root.Roles { |
| if err := c.db.AddRole(name, role); err != nil { |
| return err |
| } |
| } |
| if err := c.db.Verify(s, "root", 0); err != nil { |
| return err |
| } |
| c.consistentSnapshot = root.ConsistentSnapshot |
| } else { |
| return ErrNoRootKeys |
| } |
| |
| if snapshotJSON, ok := meta["snapshot.json"]; ok { |
| snapshot := &data.Snapshot{} |
| if err := c.db.UnmarshalTrusted(snapshotJSON, snapshot, "snapshot"); err != nil { |
| return err |
| } |
| c.snapshotVer = snapshot.Version |
| } |
| |
| if targetsJSON, ok := meta["targets.json"]; ok { |
| targets := &data.Targets{} |
| if err := c.db.UnmarshalTrusted(targetsJSON, targets, "targets"); err != nil { |
| return err |
| } |
| c.targetsVer = targets.Version |
| // FIXME(TUF-0.9) temporarily support files with leading path separators. |
| // c.targets = targets.Targets |
| c.loadTargets(targets.Targets) |
| } |
| |
| if timestampJSON, ok := meta["timestamp.json"]; ok { |
| timestamp := &data.Timestamp{} |
| if err := c.db.UnmarshalTrusted(timestampJSON, timestamp, "timestamp"); err != nil { |
| return err |
| } |
| c.timestampVer = timestamp.Version |
| } |
| |
| c.localMeta = meta |
| return nil |
| } |
| |
| // FIXME(TUF-0.9) TUF is considering removing support for target files starting |
| // with a leading path separator. In order to be backwards compatible, we'll |
| // just remove leading separators for now. |
| func (c *Client) loadTargets(targets data.TargetFiles) { |
| c.targets = make(data.TargetFiles) |
| for name, meta := range targets { |
| c.targets[name] = meta |
| c.targets[util.NormalizeTarget(name)] = meta |
| } |
| } |
| |
| // downloadMetaUnsafe downloads top-level metadata from remote storage without |
| // verifying it's length and hashes (used for example to download timestamp.json |
| // which has unknown size). It will download at most maxMetaSize bytes. |
| func (c *Client) downloadMetaUnsafe(name string, maxMetaSize int64) ([]byte, error) { |
| r, size, err := c.remote.GetMeta(name) |
| if err != nil { |
| if IsNotFound(err) { |
| return nil, ErrMissingRemoteMetadata{name} |
| } |
| return nil, ErrDownloadFailed{name, err} |
| } |
| defer r.Close() |
| |
| // return ErrMetaTooLarge if the reported size is greater than maxMetaSize |
| if size > maxMetaSize { |
| return nil, ErrMetaTooLarge{name, size, maxMetaSize} |
| } |
| |
| // although the size has been checked above, use a LimitReader in case |
| // the reported size is inaccurate, or size is -1 which indicates an |
| // unknown length |
| return ioutil.ReadAll(io.LimitReader(r, maxMetaSize)) |
| } |
| |
| // getRootAndLocalVersionsUnsafe decodes the versions stored in the local |
| // metadata without verifying signatures to protect against downgrade attacks |
| // when the root is replaced and contains new keys. It also sets the local meta |
| // cache to only contain the local root metadata. |
| func (c *Client) getRootAndLocalVersionsUnsafe() error { |
| type versionData struct { |
| Signed struct { |
| Version int |
| } |
| } |
| |
| meta, err := c.local.GetMeta() |
| if err != nil { |
| return err |
| } |
| |
| getVersion := func(name string) (int, error) { |
| m, ok := meta[name] |
| if !ok { |
| return 0, nil |
| } |
| var data versionData |
| if err := json.Unmarshal(m, &data); err != nil { |
| return 0, err |
| } |
| return data.Signed.Version, nil |
| } |
| |
| c.timestampVer, err = getVersion("timestamp.json") |
| if err != nil { |
| return err |
| } |
| c.snapshotVer, err = getVersion("snapshot.json") |
| if err != nil { |
| return err |
| } |
| c.targetsVer, err = getVersion("targets.json") |
| if err != nil { |
| return err |
| } |
| |
| root, ok := meta["root.json"] |
| if !ok { |
| return errors.New("tuf: missing local root after downloading, this should not be possible") |
| } |
| c.localMeta = map[string]json.RawMessage{"root.json": root} |
| |
| return nil |
| } |
| |
| // remoteGetFunc is the type of function the download method uses to download |
| // remote files |
| type remoteGetFunc func(string) (io.ReadCloser, int64, error) |
| |
| // downloadHashed tries to download the hashed prefixed version of the file. |
| func (c *Client) downloadHashed(file string, get remoteGetFunc, hashes data.Hashes) (io.ReadCloser, int64, error) { |
| // try each hashed path in turn, and either return the contents, |
| // try the next one if a 404 is returned, or return an error |
| for _, path := range util.HashedPaths(file, hashes) { |
| r, size, err := get(path) |
| if err != nil { |
| if IsNotFound(err) { |
| continue |
| } |
| return nil, 0, err |
| } |
| return r, size, nil |
| } |
| return nil, 0, ErrNotFound{file} |
| } |
| |
| // download downloads the given target file from remote storage using the get |
| // function, adding hashes to the path if consistent snapshots are in use |
| func (c *Client) downloadTarget(file string, get remoteGetFunc, hashes data.Hashes) (io.ReadCloser, int64, error) { |
| if c.consistentSnapshot { |
| return c.downloadHashed(file, get, hashes) |
| } else { |
| return get(file) |
| } |
| } |
| |
| // downloadVersionedMeta downloads top-level metadata from remote storage and |
| // verifies it using the given file metadata. |
| func (c *Client) downloadMeta(name string, version int, m data.FileMeta) ([]byte, error) { |
| r, size, err := func() (io.ReadCloser, int64, error) { |
| if c.consistentSnapshot { |
| path := util.VersionedPath(name, version) |
| r, size, err := c.remote.GetMeta(path) |
| if err == nil { |
| return r, size, nil |
| } |
| |
| return nil, 0, err |
| } else { |
| return c.remote.GetMeta(name) |
| } |
| }() |
| if err != nil { |
| if IsNotFound(err) { |
| return nil, ErrMissingRemoteMetadata{name} |
| } |
| return nil, err |
| } |
| defer r.Close() |
| |
| // return ErrWrongSize if the reported size is known and incorrect |
| var stream io.Reader |
| if m.Length != 0 { |
| if size >= 0 && size != m.Length { |
| return nil, ErrWrongSize{name, size, m.Length} |
| } |
| |
| // wrap the data in a LimitReader so we download at most m.Length bytes |
| stream = io.LimitReader(r, m.Length) |
| } else { |
| stream = r |
| } |
| |
| return ioutil.ReadAll(stream) |
| } |
| |
| func (c *Client) downloadMetaFromSnapshot(name string, m data.SnapshotFileMeta) ([]byte, error) { |
| b, err := c.downloadMeta(name, m.Version, m.FileMeta) |
| if err != nil { |
| return nil, err |
| } |
| |
| meta, err := util.GenerateSnapshotFileMeta(bytes.NewReader(b), m.HashAlgorithms()...) |
| if err != nil { |
| return nil, err |
| } |
| if err := util.SnapshotFileMetaEqual(meta, m); err != nil { |
| return nil, ErrDownloadFailed{name, err} |
| } |
| return b, nil |
| } |
| |
| func (c *Client) downloadMetaFromTimestamp(name string, m data.TimestampFileMeta) ([]byte, error) { |
| b, err := c.downloadMeta(name, m.Version, m.FileMeta) |
| if err != nil { |
| return nil, err |
| } |
| |
| meta, err := util.GenerateTimestampFileMeta(bytes.NewReader(b), m.HashAlgorithms()...) |
| if err != nil { |
| return nil, err |
| } |
| if err := util.TimestampFileMetaEqual(meta, m); err != nil { |
| return nil, ErrDownloadFailed{name, err} |
| } |
| return b, nil |
| } |
| |
| // decodeRoot decodes and verifies root metadata. |
| func (c *Client) decodeRoot(b json.RawMessage) error { |
| root := &data.Root{} |
| if err := c.db.Unmarshal(b, root, "root", c.rootVer); err != nil { |
| return ErrDecodeFailed{"root.json", err} |
| } |
| c.rootVer = root.Version |
| c.consistentSnapshot = root.ConsistentSnapshot |
| return nil |
| } |
| |
| // decodeSnapshot decodes and verifies snapshot metadata, and returns the new |
| // root and targets file meta. |
| func (c *Client) decodeSnapshot(b json.RawMessage) (data.SnapshotFileMeta, data.SnapshotFileMeta, error) { |
| snapshot := &data.Snapshot{} |
| if err := c.db.Unmarshal(b, snapshot, "snapshot", c.snapshotVer); err != nil { |
| return data.SnapshotFileMeta{}, data.SnapshotFileMeta{}, ErrDecodeFailed{"snapshot.json", err} |
| } |
| c.snapshotVer = snapshot.Version |
| return snapshot.Meta["root.json"], snapshot.Meta["targets.json"], nil |
| } |
| |
| // decodeTargets decodes and verifies targets metadata, sets c.targets and |
| // returns updated targets. |
| func (c *Client) decodeTargets(b json.RawMessage) (data.TargetFiles, error) { |
| targets := &data.Targets{} |
| if err := c.db.Unmarshal(b, targets, "targets", c.targetsVer); err != nil { |
| return nil, ErrDecodeFailed{"targets.json", err} |
| } |
| updatedTargets := make(data.TargetFiles) |
| for path, meta := range targets.Targets { |
| if local, ok := c.targets[path]; ok { |
| if err := util.TargetFileMetaEqual(local, meta); err == nil { |
| continue |
| } |
| } |
| updatedTargets[path] = meta |
| } |
| c.targetsVer = targets.Version |
| // FIXME(TUF-0.9) temporarily support files with leading path separators. |
| // c.targets = targets.Targets |
| c.loadTargets(targets.Targets) |
| return updatedTargets, nil |
| } |
| |
| // decodeTimestamp decodes and verifies timestamp metadata, and returns the |
| // new snapshot file meta. |
| func (c *Client) decodeTimestamp(b json.RawMessage) (data.TimestampFileMeta, error) { |
| timestamp := &data.Timestamp{} |
| if err := c.db.Unmarshal(b, timestamp, "timestamp", c.timestampVer); err != nil { |
| return data.TimestampFileMeta{}, ErrDecodeFailed{"timestamp.json", err} |
| } |
| c.timestampVer = timestamp.Version |
| return timestamp.Meta["snapshot.json"], nil |
| } |
| |
| // hasSnapshotMeta checks whether local metadata has the given meta |
| func (c *Client) hasMetaFromSnapshot(name string, m data.SnapshotFileMeta) bool { |
| b, ok := c.localMeta[name] |
| if !ok { |
| return false |
| } |
| meta, err := util.GenerateSnapshotFileMeta(bytes.NewReader(b), m.HashAlgorithms()...) |
| if err != nil { |
| return false |
| } |
| err = util.SnapshotFileMetaEqual(meta, m) |
| return err == nil |
| } |
| |
| // hasTargetsMeta checks whether local metadata has the given snapshot meta |
| func (c *Client) hasTargetsMeta(m data.SnapshotFileMeta) bool { |
| b, ok := c.localMeta["targets.json"] |
| if !ok { |
| return false |
| } |
| meta, err := util.GenerateSnapshotFileMeta(bytes.NewReader(b), m.HashAlgorithms()...) |
| if err != nil { |
| return false |
| } |
| err = util.SnapshotFileMetaEqual(meta, m) |
| return err == nil |
| } |
| |
| // hasSnapshotMeta checks whether local metadata has the given meta |
| func (c *Client) hasMetaFromTimestamp(name string, m data.TimestampFileMeta) bool { |
| b, ok := c.localMeta[name] |
| if !ok { |
| return false |
| } |
| meta, err := util.GenerateTimestampFileMeta(bytes.NewReader(b), m.HashAlgorithms()...) |
| if err != nil { |
| return false |
| } |
| err = util.TimestampFileMetaEqual(meta, m) |
| return err == nil |
| } |
| |
| type Destination interface { |
| io.Writer |
| Delete() error |
| } |
| |
| // Download downloads the given target file from remote storage into dest. |
| // |
| // dest will be deleted and an error returned in the following situations: |
| // |
| // * The target does not exist in the local targets.json |
| // * The target does not exist in remote storage |
| // * Metadata cannot be generated for the downloaded data |
| // * Generated metadata does not match local metadata for the given file |
| func (c *Client) Download(name string, dest Destination) (err error) { |
| // delete dest if there is an error |
| defer func() { |
| if err != nil { |
| dest.Delete() |
| } |
| }() |
| |
| // populate c.targets from local storage if not set |
| if c.targets == nil { |
| if err := c.getLocalMeta(); err != nil { |
| return err |
| } |
| } |
| |
| // return ErrUnknownTarget if the file is not in the local targets.json |
| normalizedName := util.NormalizeTarget(name) |
| localMeta, ok := c.targets[normalizedName] |
| if !ok { |
| return ErrUnknownTarget{name} |
| } |
| |
| // get the data from remote storage |
| r, size, err := c.downloadTarget(normalizedName, c.remote.GetTarget, localMeta.Hashes) |
| if err != nil { |
| return err |
| } |
| defer r.Close() |
| |
| // return ErrWrongSize if the reported size is known and incorrect |
| if size >= 0 && size != localMeta.Length { |
| return ErrWrongSize{name, size, localMeta.Length} |
| } |
| |
| // wrap the data in a LimitReader so we download at most localMeta.Length bytes |
| stream := io.LimitReader(r, localMeta.Length) |
| |
| // read the data, simultaneously writing it to dest and generating metadata |
| actual, err := util.GenerateTargetFileMeta(io.TeeReader(stream, dest), localMeta.HashAlgorithms()...) |
| if err != nil { |
| return ErrDownloadFailed{name, err} |
| } |
| |
| // check the data has the correct length and hashes |
| if err := util.TargetFileMetaEqual(actual, localMeta); err != nil { |
| if e, ok := err.(util.ErrWrongLength); ok { |
| return ErrWrongSize{name, e.Actual, e.Expected} |
| } |
| return ErrDownloadFailed{name, err} |
| } |
| |
| return nil |
| } |
| |
| // Target returns the target metadata for a specific target if it |
| // exists. If it does not, ErrNotFound will be returned. |
| func (c *Client) Target(name string) (data.TargetFileMeta, error) { |
| m, err := c.Targets() |
| if err != nil { |
| return data.TargetFileMeta{}, err |
| } |
| |
| target, ok := m[util.NormalizeTarget(name)] |
| if ok { |
| return target, nil |
| } |
| |
| return data.TargetFileMeta{}, ErrNotFound{name} |
| } |
| |
| // Targets returns the complete list of available targets. |
| func (c *Client) Targets() (data.TargetFiles, error) { |
| // populate c.targets from local storage if not set |
| if c.targets == nil { |
| if err := c.getLocalMeta(); err != nil { |
| return nil, err |
| } |
| } |
| return c.targets, nil |
| } |