Skip to main content

mkit_server/pipeline/
scanner_retrieval.rs

1//! Existing ticket lifetime and denial checks for the private scanner route.
2use super::{HookSet, Pipeline};
3use crate::ServerError;
4use crate::scanner_retrieval::{RetrievalResponse, service::missing};
5use crate::store::{MultipartBlobStore, NamespaceStore};
6
7impl<B: MultipartBlobStore, N: NamespaceStore, H: HookSet> Pipeline<B, N, H> {
8    #[cfg(feature = "remote-hooks")]
9    pub(super) fn retrieval_assignment(
10        op: &crate::op::Operation,
11        advance: Option<&super::advance::AdvanceWrite<'_>>,
12        repository: &str,
13        snapshot: &super::Snapshot,
14        set: &crate::indexed::inspection::InspectionSet,
15    ) -> Result<crate::scanner_retrieval::Assignment, ServerError> {
16        use super::{codec, internal, keys, meta_error};
17        let auth = op
18            .auth
19            .as_ref()
20            .ok_or_else(|| internal("retrieval requires authenticated write"))?;
21        let ref_name = match &op.kind {
22            crate::op::OpKind::UpdateRef(update) => &update.name,
23            crate::op::OpKind::AdvanceRefs { head, .. } => &head.name,
24            _ => return Err(internal("retrieval requires ref write")),
25        };
26        if advance.is_none() && !set.raw_packs().is_empty() {
27            return Err(internal("retrieval additions require tickets"));
28        }
29        let mut packs: Vec<crate::scanner_retrieval::PackGrant> = Vec::new();
30        for id in advance.map_or(&[][..], |advance| advance.ids) {
31            let raw = snapshot
32                .get(&keys::ticket(id))
33                .ok_or_else(|| internal("retrieval ticket missing"))?;
34            let ticket = codec::decode_ticket(raw).map_err(meta_error)?;
35            if !set.raw_packs().contains(&ticket.pack_id) {
36                continue;
37            }
38            if let Some(pack) = packs.iter_mut().find(|pack| pack.id == ticket.pack_id) {
39                if pack.length != ticket.bytes {
40                    return Err(internal("retrieval length mismatch"));
41                }
42                pack.tickets.push(*id);
43            } else {
44                packs.push(crate::scanner_retrieval::PackGrant {
45                    id: ticket.pack_id,
46                    length: ticket.bytes,
47                    tickets: vec![*id],
48                });
49            }
50        }
51        Ok(crate::scanner_retrieval::Assignment {
52            namespace: op.repo.namespace.as_str().to_owned(),
53            repo_name: op.repo.name.as_str().to_owned(),
54            // The signed operation provides the empty-scope binding too. Do
55            // not infer scope from the resulting pair or existing membership.
56            repository: repository.to_owned(),
57            ref_name: ref_name.to_owned(),
58            signer: auth.signer,
59            packs,
60        })
61    }
62
63    /// Whether adapters may mount the private route. Default false.
64    #[must_use]
65    pub fn scanner_retrieval_enabled(&self) -> bool {
66        self.cfg.scanner_retrieval.is_some()
67    }
68
69    /// Verify both credentials, strongly check existing bound ticket state and
70    /// global denial, then read one bounded raw pack range. No public membership
71    /// authorization or pack decoding is involved.
72    ///
73    /// # Errors
74    /// Uniform `not_found`, including storage errors and exhausted budgets.
75    // Preserve the async interface when the feature containing its awaits is disabled.
76    #[cfg_attr(not(feature = "remote-hooks"), allow(clippy::unused_async))]
77    pub async fn retrieve_scanner_pack(
78        &self,
79        body: &[u8],
80        headers: &mkit_core::write_auth::Headers,
81    ) -> Result<RetrievalResponse, ServerError> {
82        #[cfg(feature = "remote-hooks")]
83        let result = self
84            .retrieve_inner(body, headers)
85            .await
86            .map_err(|_| missing());
87        #[cfg(not(feature = "remote-hooks"))]
88        let result: Result<RetrievalResponse, ServerError> = {
89            let _ = (body, headers);
90            Err(missing())
91        };
92        self.metrics.incr(
93            "mkit_server_scanner_retrieval_calls",
94            &[("result", if result.is_ok() { "ok" } else { "not_found" })],
95            1,
96        );
97        if let Ok(response) = &result {
98            self.metrics.incr(
99                "mkit_server_scanner_retrieval_bytes",
100                &[],
101                response.bytes.len() as u64,
102            );
103        }
104        result
105    }
106
107    #[cfg(feature = "remote-hooks")]
108    async fn retrieve_inner(
109        &self,
110        body: &[u8],
111        headers: &mkit_core::write_auth::Headers,
112    ) -> Result<RetrievalResponse, ServerError> {
113        use crate::indexed::budget::{Budgeted, SliceBudget};
114        use crate::scanner_retrieval::{MAX_CALLS, MAX_RESPONSE_BYTES};
115        use crate::store::{BlobKey, BlobStore, ByteRange};
116        let (claims, request, repo) =
117            request_authority(&self.cfg, self.clock.as_ref(), body, headers)?;
118        let pack_id = mkit_core::hash::from_hex(&request.pack_id).map_err(|_| missing())?;
119        if mkit_core::hash::to_hex(&pack_id) != request.pack_id {
120            return Err(missing());
121        }
122        let pack = claims
123            .assignment
124            .packs
125            .iter()
126            .find(|pack| pack.id == pack_id)
127            .ok_or_else(missing)?;
128        let (start, end, partial) = match (request.start, request.end_inclusive) {
129            (None, None) => (0, pack.length.checked_sub(1).ok_or_else(missing)?, false),
130            (Some(start), Some(end)) => (start, end, true),
131            _ => return Err(missing()),
132        };
133        let length = end
134            .checked_sub(start)
135            .and_then(|n| n.checked_add(1))
136            .ok_or_else(missing)?;
137        if end >= pack.length || length > MAX_RESPONSE_BYTES as u64 {
138            return Err(missing());
139        }
140        let budget = SliceBudget::new(MAX_CALLS);
141        let meta = Budgeted::new(&self.meta, &budget);
142        let blobs = Budgeted::new(&self.blobs, &budget);
143        let partition = self.shards.ref_shard(&repo, &claims.assignment.ref_name);
144        check_tickets(
145            &meta,
146            &partition,
147            &repo,
148            &claims.assignment,
149            pack,
150            self.clock.as_ref(),
151        )
152        .await?;
153        crate::takedown::denial::require_pack_clear_for_scanner(
154            &meta,
155            self.shards.as_ref(),
156            &repo,
157            &pack.id,
158        )
159        .await?;
160        let now = u64::try_from(self.clock.now_ms()).map_err(|_| missing())?;
161        if now >= claims.expires_at_ms {
162            return Err(missing());
163        }
164        let raw = blobs
165            .get(
166                &BlobKey::pack(pack.id),
167                Some(ByteRange {
168                    start,
169                    end_inclusive: end,
170                }),
171            )
172            .await
173            .map_err(|_| missing())?
174            .ok_or_else(missing)?;
175        let bytes = collect_segment(raw, length).await?;
176        let now = u64::try_from(self.clock.now_ms()).map_err(|_| missing())?;
177        if now >= claims.expires_at_ms {
178            return Err(missing());
179        }
180        check_tickets(
181            &meta,
182            &partition,
183            &repo,
184            &claims.assignment,
185            pack,
186            self.clock.as_ref(),
187        )
188        .await?;
189        let now = u64::try_from(self.clock.now_ms()).map_err(|_| missing())?;
190        if now >= claims.expires_at_ms {
191            return Err(missing());
192        }
193        Ok(RetrievalResponse {
194            bytes,
195            start,
196            total: pack.length,
197            partial,
198        })
199    }
200}
201
202#[cfg(feature = "remote-hooks")]
203#[derive(serde::Deserialize)]
204#[serde(deny_unknown_fields)]
205struct Request {
206    capability: String,
207    pack_id: String,
208    start: Option<u64>,
209    end_inclusive: Option<u64>,
210}
211
212#[cfg(feature = "remote-hooks")]
213fn request_authority(
214    cfg: &super::PipelineConfig,
215    clock: &dyn crate::Clock,
216    body: &[u8],
217    headers: &mkit_core::write_auth::Headers,
218) -> Result<(crate::scanner_retrieval::Claims, Request, crate::RepoId), ServerError> {
219    use crate::scanner_retrieval::{MAX_REQUEST_BYTES, PATH};
220    if body.len() > MAX_REQUEST_BYTES {
221        return Err(missing());
222    }
223    let config = cfg.scanner_retrieval.as_ref().ok_or_else(missing)?;
224    let crate::pipeline::AuthMode::AuthV2(auth) = &cfg.auth else {
225        return Err(missing());
226    };
227    let now = u64::try_from(clock.now_ms()).map_err(|_| missing())?;
228    let request: Request = serde_json::from_slice(body).map_err(|_| missing())?;
229    let claims = config.verify(&request.capability, auth.audience(), now)?;
230    let signed = crate::auth_v2::verify_unary_for(
231        auth,
232        &claims.assignment.repository,
233        PATH,
234        body,
235        clock.now_ms(),
236        headers,
237    )?;
238    if !config.accepts(&signed.signer) {
239        return Err(missing());
240    }
241    let repo = cfg
242        .addressing
243        .resolve(Some(&claims.assignment.repository), true)?
244        .repo;
245    if repo.namespace.as_str() != claims.assignment.namespace
246        || repo.name.as_str() != claims.assignment.repo_name
247    {
248        return Err(missing());
249    }
250    Ok((claims, request, repo))
251}
252
253#[cfg(feature = "remote-hooks")]
254async fn check_tickets<N: NamespaceStore>(
255    store: &N,
256    partition: &crate::Partition,
257    repo: &crate::RepoId,
258    assignment: &crate::scanner_retrieval::Assignment,
259    pack: &crate::scanner_retrieval::PackGrant,
260    clock: &dyn crate::Clock,
261) -> Result<(), ServerError> {
262    use crate::store::{codec, keys, tickets};
263    let keys: Vec<_> = pack.tickets.iter().map(keys::ticket).collect();
264    let rows = store
265        .get_many(partition, &keys)
266        .await
267        .map_err(|_| missing())?;
268    if rows.len() != keys.len() {
269        return Err(missing());
270    }
271    let now = u64::try_from(clock.now_ms()).map_err(|_| missing())?;
272    for (id, raw) in pack.tickets.iter().zip(rows) {
273        let t = codec::decode_ticket(&raw.ok_or_else(missing)?).map_err(|_| missing())?;
274        if tickets::ticket_id(&t.reservation_id) != *id
275            || t.repo != repo.name
276            || t.ref_name != assignment.ref_name
277            || t.signer != assignment.signer
278            || t.pack_id != pack.id
279            || t.bytes != pack.length
280            || now >= t.expires_at_ms
281        {
282            return Err(missing());
283        }
284    }
285    Ok(())
286}
287
288#[cfg(feature = "remote-hooks")]
289async fn collect_segment(raw: crate::BlobBody, length: u64) -> Result<bytes::Bytes, ServerError> {
290    use crate::BlobBody;
291    use futures::StreamExt as _;
292    let length = usize::try_from(length).map_err(|_| missing())?;
293    if length > crate::scanner_retrieval::MAX_RESPONSE_BYTES {
294        return Err(missing());
295    }
296    let BlobBody::Stream { len, mut stream } = raw else {
297        return match raw {
298            BlobBody::Bytes(bytes) if bytes.len() == length => Ok(bytes),
299            _ => Err(missing()),
300        };
301    };
302    if len != length as u64 {
303        return Err(missing());
304    }
305    let mut bytes = bytes::BytesMut::with_capacity(length);
306    while let Some(chunk) = stream.next().await {
307        let chunk = chunk.map_err(|_| missing())?;
308        if chunk.len() > length - bytes.len() {
309            return Err(missing());
310        }
311        bytes.extend_from_slice(&chunk);
312    }
313    if bytes.len() != length {
314        return Err(missing());
315    }
316    Ok(bytes.freeze())
317}