Skip to content

refactor(tuf)!: stabilize the sigstore-tuf API for 1.0 - #317

Open
wolfv wants to merge 2 commits into
mainfrom
refactor/tuf-1.0
Open

wolfv wants to merge 2 commits into
mainfrom
refactor/tuf-1.0

Conversation

@wolfv

@wolfv wolfv commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator

sigstore-tuf was going to stay on 0.x. This PR reviews and finishes its API instead, so it can go to 1.0 with the rest of the workspace. The verification logic is unchanged. The changes are to the public surface and error reporting, plus two small hardening fixes.

Versioning

  • sigstore-tuf uses version.workspace, and the release-plz note about keeping it on 0.x is removed.

API changes

  • Feature: fetch is renamed to client, matching sigstore-rekor and sigstore-tsa. rustls and native-tls enable it, as in those crates.
  • Error:
    • Removed InvalidSignatureEncoding, which was never constructed, and InvalidTimestamp. The public API no longer exposes hex::FromHexError or jiff::Error.
    • Transport is now { message, source }, so the reqwest error is kept as a source instead of being formatted into the message. Error::transport() and Error::transport_with_source() are for custom Repository implementations.
    • New variants:
      • Io { context, source } for store I/O, which was reported as Transport before.
      • NotRefreshed and TargetNotFound(path), both reported as Malformed before.
    • Expired.expires is a jiff::Timestamp.
  • Metadata:
    • expires on Root, Timestamp, Snapshot and Targets is a jiff::Timestamp, parsed with the metadata.
    • Role is sealed, Role::expires() returns the timestamp, and is_expired() returns a bool.
    • The metadata structs, Key, KeyVal and Signature are now #[non_exhaustive], so fields from future spec versions can be added.
  • UpdaterConfig: now #[non_exhaustive]. Start from default() and set fields. max_root_rotations is a u32, like max_delegations.
  • MetadataStore::load: returns Result<Option<Vec<u8>>>, so a store can report I/O errors and not only misses. FileStore returns Ok(None) only for NotFound. The Updater logs read errors and treats them as cache misses. Everything it loads is re-verified anyway. StoreRepository passes the errors through.
  • Updater: get_targetinfo is renamed to get_target_info, and download_target takes &self.

Fixes

  • Metadata and targets now share one length/hash check. They were two copies of the same logic.
  • update_root uses checked_add for trusted + 1. Before, a pinned root at version u64::MAX would panic in debug builds.
  • The max_delegations docs said it bounds tree depth. It actually bounds the number of roles visited, as in python-tuf.

Downstream

sigstore-trust-root flattens tuf errors into Error::Tuf(String). It now renders the whole source chain, so HTTP and I/O causes still show up in its messages.

Testing

  • The workspace tests pass (650), and clippy --workspace --all-targets --all-features -D warnings and rustdoc with -D warnings are clean.
  • sigstore-tuf builds with --no-default-features, and sigstore-trust-root builds with only tuf.
  • New tests cover the typed Expired error, NotRefreshed and TargetNotFound.
  • The TUF conformance suite runs in this PR's CI. It checks the typed expires parsing against the reference vectors.

Signed-off-by: Wolf Vollprecht w.vollprecht@gmail.com

🤖 Generated with Claude Code

sigstore-tuf now follows the workspace version and goes to 1.0 with the
other crates.

- Rename the HTTP transport feature from fetch to client, matching rekor
  and tsa.
- Error: drop the unused InvalidSignatureEncoding and InvalidTimestamp
  variants (no more hex or jiff error types in the API), give Transport
  an optional source, and add Io, NotRefreshed and TargetNotFound in
  place of stringly Malformed/Transport uses.
- Role expiry is a jiff::Timestamp parsed with the metadata; Role is
  sealed and is_expired no longer returns a Result.
- Mark the metadata, key and UpdaterConfig structs #[non_exhaustive];
  max_root_rotations is a u32 like max_delegations.
- MetadataStore::load returns Result<Option<_>> so stores can report
  I/O errors; the Updater logs them and treats them as cache misses.
- Rename Updater::get_targetinfo to get_target_info; download_target
  takes &self.
- Share one length/hash check between metadata and targets, and guard
  the root version increment against overflow.
- trust-root keeps the tuf error's source chain in its messages.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Wolf Vollprecht <w.vollprecht@gmail.com>

@jku jku left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couple of things that maybe could be changed (or I could be mistaken), otherwise lgtm

Comment thread crates/sigstore-tuf/src/error.rs Outdated
}
}

pub(crate) fn io(context: impl Into<String>, source: std::io::Error) -> Self {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume from transport() that this should be pub as well:

Suggested change
pub(crate) fn io(context: impl Into<String>, source: std::io::Error) -> Self {
pub fn io(context: impl Into<String>, source: std::io::Error) -> Self {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, MetadataStore is public, so outside implementations need it. Made pub in 4c5a223.

Comment on lines +667 to +676
fn error_chain(error: &sigstore_tuf::Error) -> String {
let mut message = error.to_string();
let mut source = std::error::Error::source(error);
while let Some(cause) = source {
message.push_str(": ");
message.push_str(&cause.to_string());
source = cause.source();
}
message
}

@jku jku Oct 7, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

gemini tells me this combined with Io and Transport in errors.rs leads to inconsistent Display vs. Error::source():

  • When error_chain walks .source(), it appends : {cause} onto error.to_string(). For Error::Io, Error::Json, and Error::Crypto, the cause is rendered twice (e.g., "reading /path/timestamp.json: permission denied: permission denied")
  • Callers like conformance_client.rs:234 that format sigstore_tuf::Error with {e} or %e lose the underlying reqwest::Error / url::ParseError on Error::Transport.

I did not confirm this myself but maybe you can tell your ai what my ai said?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your AI was right on both counts. Io, Json and Crypto put the cause in Display and returned it from source(), while Transport only did the latter. Fixed in 4c5a223 with one rule for every variant: Display describes only the error itself, and the cause is reached through source(). The warn! logs in client.rs and conformance_client now print the full chain, so nothing is lost or duplicated. I also added a regression test.

Comment thread crates/sigstore-tuf/Cargo.toml
@jku

jku commented Oct 7, 2026

Copy link
Copy Markdown
Member

I also noticed some related nits in our feature checking ... but I think that could maybe use a general refactor so I'll look into that in a separate PR

Error::Io, Json and Crypto repeated their cause in Display while also
returning it from source(), so chain renderers printed it twice. Display
now describes only the error itself; causes come through source().
Internal warn! logs and the conformance example render the full chain.

Error::io is now public so MetadataStore implementations can build
Error::Io values.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants