Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
68aebaa
scope artifacts to groups
dannash100 Sep 6, 2026
87435a3
test artifact scoping
dannash100 Sep 6, 2026
96f0e79
regenerate api surface
dannash100 Sep 6, 2026
5c43219
show held artifacts to operators
dannash100 Sep 6, 2026
35f000f
register artifacts for a group
dannash100 Sep 6, 2026
e400b92
test group scoped artifacts
dannash100 Sep 6, 2026
9ecea71
tighten auth comment
dannash100 Sep 6, 2026
e53392b
Merge branch 'epic/deployment-artefacts' into feat/group-scoped-artif…
dannash100 Sep 6, 2026
22be6dc
held artifacts take no url
dannash100 Sep 6, 2026
6e383dc
drop unused resolver
dannash100 Sep 6, 2026
dc7e06e
Merge branch 'epic/deployment-artefacts' into feat/group-scoped-artif…
dannash100 Sep 6, 2026
0636c50
offer flag per scope
dannash100 Sep 7, 2026
42f055f
scope redaction gap
dannash100 Sep 7, 2026
dba3c1f
bad registrations are 400
dannash100 Sep 7, 2026
27405eb
pin artifact resolution rules
dannash100 Sep 7, 2026
9e8023f
assert which artifact is hidden
dannash100 Sep 7, 2026
3279655
cover artifact retention and scope
dannash100 Sep 7, 2026
f2aa605
cover credential and media type
dannash100 Sep 7, 2026
642b435
cover the artifact form
dannash100 Sep 7, 2026
486ced0
refuse a blank location
dannash100 Sep 7, 2026
20a75d6
size the artifact body limit
dannash100 Sep 7, 2026
ee30a20
refuse oversized uploads in the form
dannash100 Sep 7, 2026
868707a
refuse a rename onto a taken identity
dannash100 Sep 7, 2026
9155990
refuse a blank registration
dannash100 Sep 7, 2026
033dc91
cover the offered flag over the wire
dannash100 Sep 7, 2026
a9190fb
pin error slugs to their headings
dannash100 Sep 7, 2026
7de1f95
require an operator identity for artifacts
dannash100 Sep 7, 2026
62107d6
propagate auth faults
dannash100 Sep 7, 2026
3231f52
override ranges across scopes
dannash100 Sep 7, 2026
e2d1630
check digests on arrival
dannash100 Sep 7, 2026
58354d9
record a releaser digest
dannash100 Sep 7, 2026
5ed9d42
answer an exact version for itself
dannash100 Sep 7, 2026
75e53b3
Merge branch 'epic/deployment-artefacts' into feat/group-scoped-artif…
dannash100 Sep 8, 2026
e27645e
settle where an artifact rests
dannash100 Sep 8, 2026
cc8a82d
refuse rather than fault
dannash100 Sep 9, 2026
b6d94f1
never render proxied bytes
dannash100 Sep 9, 2026
0a81a86
sri digests and raw uploads
dannash100 Sep 9, 2026
9cd0300
settle artifact reads and digests
dannash100 Sep 9, 2026
ae34d7d
dedup before the identity index
dannash100 Sep 10, 2026
c89bf27
refuse before drafting a version
dannash100 Sep 10, 2026
4e233d5
surface a digest failure
dannash100 Sep 10, 2026
115f19a
read one artifact by id
dannash100 Sep 10, 2026
e075277
hash before taking a connection
dannash100 Sep 10, 2026
70ebf5a
name only the groups listed
dannash100 Sep 10, 2026
795a2bf
refuse a stale credential
dannash100 Sep 14, 2026
5ccb55e
constrain blanks in sql
dannash100 Sep 14, 2026
64c6f8d
trim the artifact docs
dannash100 Sep 14, 2026
f59f65d
drop the slug anchor test
dannash100 Sep 14, 2026
32d5c7b
seed real digests in fixtures
dannash100 Sep 14, 2026
52fd97c
hold artifacts outside postgres
dannash100 Sep 14, 2026
ba49df8
note where the bytes rest
dannash100 Sep 14, 2026
69859dc
clean up a refused registration
dannash100 Sep 14, 2026
f146a26
format the store tests
dannash100 Sep 14, 2026
6bda879
sweep artifacts nothing reaches
dannash100 Sep 14, 2026
0ec4209
say what a deletion takes
dannash100 Sep 14, 2026
04a0c35
note what raising the cap takes
dannash100 Sep 16, 2026
135f210
Merge branch 'epic/deployment-artefacts' into feat/group-scoped-artif…
dannash100 Sep 16, 2026
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
12 changes: 10 additions & 2 deletions .workhorse/specs/platform/artifacts.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,21 @@ A publisher sends the bytes on the connection it registers over and is issued no
Canopy holds such an artifact in storage of its own, apart from any group's backup repo, so an artifact carries the retention, access, and cost basis of an artifact rather than those a backup repo is kept under (see [BAK](../public-server/backup.md)).
Where Canopy puts them is its own, and no caller addresses them there.
Canopy holds an artifact's bytes for as long as that artifact is registered, and keeps none of what it has stopped serving.
A registration or a deregistration that fails once the bytes have moved can leave bytes no artifact reaches, and Canopy drops those as well rather than letting them accumulate.
It does not expire bytes by age: an artifact's bytes are kept as long as it is registered however long that is, so age alone never says an artifact is finished with.

Canopy serves the bytes only to a caller the artifact is offered to.
The boundary is therefore enforced on the read rather than resting on a location being hard to guess.
An artifact whose bytes Canopy cannot produce is answered as one that does not exist, identically to an artifact the caller is not offered, so what Canopy has failed to keep is not discoverable through the read.

An artifact Canopy holds and an artifact Canopy records a location for are one thing to whoever is offered it.
It is offered one artifact per type and platform, and where the bytes rest is not part of what it is offered.

## What a version offers

A read names a version range, and an exact version is a valid one.
An exact version is answered for itself; a range is answered for the latest published version it covers that no known issue covers.

Canopy offers a caller one artifact per type and platform, chosen from the artifacts that caller may see: those belonging to no group, and those scoped to the caller's group where that group is known.
Where several match, the most specific is offered.
An artifact scoped to the caller's group is more specific than one belonging to no group.
Expand Down Expand Up @@ -72,7 +78,9 @@ Canopy records which device registered an artifact and, where the registration n

## Digests

An artifact carries a digest where whoever registers it records one, and a group-scoped artifact carries one always.
Canopy verifies a group-scoped artifact's bytes against its digest as they arrive and refuses the registration on a mismatch, so a corrupted upload is refused while whoever sent it is still there to send it again.
An artifact carries a digest where whoever registers it records one, and an artifact Canopy holds carries one always.
A digest is expressed in Subresource Integrity format, using SHA-256.
A registration naming anything else is refused.
Canopy verifies the bytes it holds against their digest as they arrive and refuses the registration on a mismatch, so a corrupted upload is refused while whoever sent it is still there to send it again.
It verifies them again as it serves them and refuses them on a mismatch, so an artifact corrupted after it was taken in fails the read rather than reaching a server as the artifact it is not.
An unscoped artifact is read from its location by the caller rather than by Canopy, so its digest is what that caller checks what it fetched against, and an artifact registered without one is fetched unchecked.
5 changes: 5 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions ERRORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ Issued when a database resource (such as a version, server, or other entity) can

Issued when a version range is valid, but does not match any of the available versions.

## Artifact not found

Issued when the version has no artifact with the given id that the caller is offered. An artifact scoped to a group the caller is not in is reported the same way as one that does not exist, so which groups hold an artifact cannot be discovered through this endpoint.

## Artifact digest mismatch

Issued when the bytes Canopy holds for an artifact do not match the digest recorded for it. The artifact is not served: a corrupted artifact fails the read rather than reaching a server as the artifact it is not.

## Unusable range

Issued when a version range is syntactically valid, but not usable to obtain concrete versions.
Expand Down
13 changes: 12 additions & 1 deletion crates/canopy-api/src/generated.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ pub const OPENAPI_VERSION: &str = "1.0.1";

/// BLAKE3 digest of that document, so a document that changed without the
/// version moving with it can be told from one that did not.
pub const OPENAPI_BLAKE3: &str = "7618bb56960b10e83dac565b3dde53060531f2ef31bc1a7fa4fe6cf308ef5f02";
pub const OPENAPI_BLAKE3: &str = "7f018c9f89e7dacc44a693ef7d042946a314225a1b998d69a4e8154ef09199a6";

/// Error types.
pub mod error {
Expand Down Expand Up @@ -260,6 +260,13 @@ package, or other file published for a given type and platform.*/
/// ],
/// "format": "uuid"
/// },
/// "digest": {
/// "description": "Subresource Integrity digest of the artifact's bytes, e.g.\n`sha256-LCTbqp…`, where one was recorded.",
/// "type": [
/// "string",
/// "null"
/// ]
/// },
/// "download_url": {
/// "description": "URL the artifact can be downloaded from.",
/// "type": "string"
Expand Down Expand Up @@ -302,6 +309,10 @@ pub struct Artifact {
releaser device rather than created by an operator.*/
#[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
pub device_id: ::std::option::Option<::uuid::Uuid>,
/**Subresource Integrity digest of the artifact's bytes, e.g.
`sha256-LCTbqp…`, where one was recorded.*/
#[serde(default, skip_serializing_if = "::std::option::Option::is_none")]
pub digest: ::std::option::Option<::std::string::String>,
///URL the artifact can be downloaded from.
pub download_url: ::std::string::String,
///Unique identifier of the artifact.
Expand Down
44 changes: 44 additions & 0 deletions crates/commons-errors/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ pub enum AppError {
#[error("no versions match given range")]
NoMatchingVersions,

#[error("no such artifact for this version")]
ArtifactNotFound,

#[error("artifact does not match its digest")]
ArtifactDigestMismatch,

#[error("version range is not usable")]
UnusableRange,

Expand Down Expand Up @@ -249,6 +255,12 @@ impl AppError {
match self {
Self::NotImplemented => StatusCode::NOT_IMPLEMENTED,
Self::NoMatchingVersions => StatusCode::NOT_FOUND,
// An artifact a caller is not offered is missing in exactly the
// way one that never existed is, so which groups hold an artifact
// is not enumerable through this endpoint.
// spec: ART#who-is-offered-a-group-scoped-artifact
Self::ArtifactNotFound => StatusCode::NOT_FOUND,
Self::ArtifactDigestMismatch => StatusCode::INTERNAL_SERVER_ERROR,
Self::UnusableRange => StatusCode::BAD_REQUEST,
// Both arise purely from what a client sent: a version segment in
// a URL path, or the `X-Version` header. Nothing on the server is
Expand Down Expand Up @@ -322,6 +334,8 @@ impl AppError {
Self::Tera(_) => "render",
Self::Io(_) => "io",
Self::NoMatchingVersions => "no-matching-versions",
Self::ArtifactNotFound => "artifact-not-found",
Self::ArtifactDigestMismatch => "artifact-digest-mismatch",
Self::UnusableRange => "unusable-range",
Self::Timesync(_) => "timesync",
Self::AuthMissingHeader(_) => "auth-missing-header",
Expand Down Expand Up @@ -376,3 +390,33 @@ impl<'de> Deserialize<'de> for AppError {
Ok(AppError::Problem(Box::new(value)))
}
}

#[cfg(test)]
mod tests {
use super::*;

Comment thread
review-hero[bot] marked this conversation as resolved.
/// Input an operator or a client controls answers as their mistake, not as
/// a fault, and carries the slug its documentation is written under.
#[test]
fn a_client_mistake_is_not_a_fault() {
for (error, status, slug) in [
(
AppError::BadRequest("no".into()),
StatusCode::BAD_REQUEST,
"/errors/bad-request",
),
(
AppError::Conflict("taken".into()),
StatusCode::CONFLICT,
"/errors/conflict",
),
] {
let problem = error.to_problem_details();
assert_eq!(problem.status, Some(status));
assert_eq!(
problem.r#type.as_ref().map(ToString::to_string).as_deref(),
Some(slug)
);
}
}
}
5 changes: 5 additions & 0 deletions crates/commons-servers/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ age = { version = "0.12.1", default-features = false }
aws-config.workspace = true
aws-credential-types = "1.3.0"
aws-sdk-route53 = "1.118.0"
aws-sdk-s3.workspace = true
aws-sdk-sts.workspace = true
axum = { workspace = true, features = ["json", "macros"] }
axum-client-ip = { version = "1.3.1", features = ["forwarded-header"] }
axum-server-timing = "3.0.1"
Expand Down Expand Up @@ -56,9 +58,12 @@ tower-http = { version = "0.7.0", features = [
"trace",
] }
tracing.workspace = true
uuid = { version = "1.23.1", features = ["v4"] }
# `verify` gives `verify_signature` on a parsed CSR — proof the sender holds the
# key it asks Canopy to certify. Backed by ring, already a dependency here.
x509-parser = { version = "0.18.1", features = ["verify"] }

[dev-dependencies]
aws-sdk-s3 = { workspace = true, features = ["test-util"] }
aws-smithy-mocks.workspace = true
axum-test = { workspace = true }
Loading