debian_packaging/repository/
http.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5/*! Debian repository HTTP client.
6
7This module provides functionality for interfacing with HTTP based Debian
8repositories.
9*/
10
11use {
12    crate::{
13        error::{DebianError, Result},
14        io::DataResolver,
15        repository::{release::ReleaseFile, Compression, ReleaseReader, RepositoryRootReader},
16    },
17    async_trait::async_trait,
18    futures::{stream::TryStreamExt, AsyncRead},
19    reqwest::{Client, ClientBuilder, IntoUrl, StatusCode, Url},
20    std::pin::Pin,
21};
22
23/// Default HTTP user agent string.
24pub const USER_AGENT: &str =
25    "debian-packaging Rust crate (https://crates.io/crates/debian-packaging)";
26
27async fn fetch_url(
28    client: &Client,
29    root_url: &Url,
30    path: &str,
31) -> Result<Pin<Box<dyn AsyncRead + Send>>> {
32    let request_url = root_url.join(path)?;
33
34    let res = client.get(request_url.clone()).send().await.map_err(|e| {
35        DebianError::RepositoryIoPath(
36            path.to_string(),
37            std::io::Error::new(
38                std::io::ErrorKind::Other,
39                format!("error sending HTTP request: {:?}", e),
40            ),
41        )
42    })?;
43
44    let res = res.error_for_status().map_err(|e| {
45        if e.status() == Some(StatusCode::NOT_FOUND) {
46            DebianError::RepositoryIoPath(
47                path.to_string(),
48                std::io::Error::new(
49                    std::io::ErrorKind::NotFound,
50                    format!("HTTP 404 for {}", request_url),
51                ),
52            )
53        } else {
54            DebianError::RepositoryIoPath(
55                path.to_string(),
56                std::io::Error::new(
57                    std::io::ErrorKind::Other,
58                    format!("bad HTTP status code: {:?}", e),
59                ),
60            )
61        }
62    })?;
63
64    Ok(Box::pin(
65        res.bytes_stream()
66            .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, format!("{:?}", e)))
67            .into_async_read(),
68    ))
69}
70
71/// Client for a Debian repository served via HTTP.
72///
73/// Instances are bound to a base URL, which represents the base directory.
74///
75/// Distributions (typically) exist in a `dists/<distribution>` directory.
76/// Distributions have an `InRelease` and/or `Release` file under it.
77#[derive(Debug)]
78pub struct HttpRepositoryClient {
79    /// HTTP client to use.
80    client: Client,
81
82    /// Base URL for this Debian archive.
83    ///
84    /// Contains both distributions and the files pool.
85    root_url: Url,
86}
87
88impl HttpRepositoryClient {
89    /// Construct an instance bound to the specified URL.
90    pub fn new(url: impl IntoUrl) -> Result<Self> {
91        let builder = ClientBuilder::new().user_agent(USER_AGENT);
92
93        Self::new_client(builder.build()?, url)
94    }
95
96    /// Construct an instance using the given [Client] and URL.
97    ///
98    /// The given URL should be the value that follows the
99    /// `deb` line in apt sources files. e.g. for
100    /// `deb https://deb.debian.org/debian stable main`, the value would be
101    /// `https://deb.debian.org/debian`. The URL typically has a `dists/` directory
102    /// underneath.
103    pub fn new_client(client: Client, url: impl IntoUrl) -> Result<Self> {
104        let mut root_url = url.into_url()?;
105
106        // Trailing URLs are significant to the Url type when we .join(). So ensure
107        // the URL has a trailing path.
108        if !root_url.path().ends_with('/') {
109            root_url.set_path(&format!("{}/", root_url.path()));
110        }
111
112        Ok(Self { client, root_url })
113    }
114}
115
116#[async_trait]
117impl DataResolver for HttpRepositoryClient {
118    async fn get_path(&self, path: &str) -> Result<Pin<Box<dyn AsyncRead + Send>>> {
119        fetch_url(&self.client, &self.root_url, path).await
120    }
121}
122
123#[async_trait]
124impl RepositoryRootReader for HttpRepositoryClient {
125    fn url(&self) -> Result<Url> {
126        Ok(self.root_url.clone())
127    }
128
129    async fn release_reader_with_distribution_path(
130        &self,
131        path: &str,
132    ) -> Result<Box<dyn ReleaseReader>> {
133        let distribution_path = path.trim_matches('/').to_string();
134        let inrelease_path = join_path(&distribution_path, "InRelease");
135        let release_path = join_path(&distribution_path, "Release");
136        let mut root_url = self.root_url.join(&distribution_path)?;
137
138        // Trailing URLs are significant to the Url type when we .join(). So ensure
139        // the URL has a trailing path.
140        if !root_url.path().ends_with('/') {
141            root_url.set_path(&format!("{}/", root_url.path()));
142        }
143
144        let release = self
145            .fetch_inrelease_or_release(&inrelease_path, &release_path)
146            .await?;
147
148        let fetch_compression = Compression::default_preferred_order()
149            .next()
150            .expect("iterator should not be empty");
151
152        Ok(Box::new(HttpReleaseClient {
153            client: self.client.clone(),
154            root_url,
155            relative_path: distribution_path,
156            release,
157            fetch_compression,
158        }))
159    }
160}
161
162fn join_path(a: &str, b: &str) -> String {
163    format!("{}/{}", a.trim_matches('/'), b.trim_start_matches('/'))
164}
165
166/// Repository HTTP client bound to a parsed `Release` or `InRelease` file.
167pub struct HttpReleaseClient {
168    client: Client,
169    root_url: Url,
170    relative_path: String,
171    release: ReleaseFile<'static>,
172    fetch_compression: Compression,
173}
174
175#[async_trait]
176impl DataResolver for HttpReleaseClient {
177    async fn get_path(&self, path: &str) -> Result<Pin<Box<dyn AsyncRead + Send>>> {
178        fetch_url(&self.client, &self.root_url, path).await
179    }
180}
181
182#[async_trait]
183impl ReleaseReader for HttpReleaseClient {
184    fn url(&self) -> Result<Url> {
185        Ok(self.root_url.clone())
186    }
187
188    fn root_relative_path(&self) -> &str {
189        &self.relative_path
190    }
191
192    fn release_file(&self) -> &ReleaseFile<'static> {
193        &self.release
194    }
195
196    fn preferred_compression(&self) -> Compression {
197        self.fetch_compression
198    }
199
200    fn set_preferred_compression(&mut self, compression: Compression) {
201        self.fetch_compression = compression;
202    }
203}
204
205#[cfg(test)]
206mod test {
207    use {
208        super::*,
209        crate::{
210            dependency::BinaryDependency, dependency_resolution::DependencyResolver, error::Result,
211            io::ContentDigest, repository::release::ChecksumType,
212        },
213    };
214
215    const BULLSEYE_URL: &str = "http://snapshot.debian.org/archive/debian/20211120T085721Z";
216
217    #[tokio::test]
218    async fn bullseye_release() -> Result<()> {
219        let root = HttpRepositoryClient::new(BULLSEYE_URL)?;
220
221        let release = root.release_reader("bullseye").await?;
222
223        let packages = release.resolve_packages("main", "amd64", false).await?;
224        assert_eq!(packages.len(), 58606);
225
226        let sources = release.sources_indices_entries()?;
227        assert_eq!(sources.len(), 9);
228
229        let p = packages.iter().next().unwrap();
230        assert_eq!(p.package()?, "0ad");
231        assert_eq!(
232            p.field_str("SHA256"),
233            Some("610e9f9c41be18af516dd64a6dc1316dbfe1bb8989c52bafa556de9e381d3e29")
234        );
235
236        let p = packages.iter().last().unwrap();
237        assert_eq!(p.package()?, "python3-zzzeeksphinx");
238        assert_eq!(
239            p.field_str("SHA256"),
240            Some("6e35f5805e808c19becd3b9ce25c4cf40c41aa0cf5d81fab317198ded917fec1")
241        );
242
243        // Make sure dependency syntax parsing works.
244        let mut resolver = DependencyResolver::default();
245        resolver.load_binary_packages(packages.iter()).unwrap();
246
247        for p in packages.iter() {
248            resolver
249                .find_direct_binary_package_dependencies(p, BinaryDependency::Depends)
250                .unwrap();
251        }
252
253        let deps = resolver
254            .find_transitive_binary_package_dependencies(
255                p,
256                [
257                    BinaryDependency::Depends,
258                    BinaryDependency::PreDepends,
259                    BinaryDependency::Recommends,
260                ]
261                .into_iter(),
262            )
263            .unwrap();
264
265        let sources = deps.packages_with_sources().collect::<Vec<_>>();
266        assert_eq!(sources.len(), 128);
267
268        Ok(())
269    }
270
271    #[tokio::test]
272    async fn bullseye_sources() -> Result<()> {
273        let root = HttpRepositoryClient::new(BULLSEYE_URL)?;
274
275        let release = root.release_reader("bullseye").await?;
276
277        let sources_entries = release.sources_indices_entries()?;
278        assert_eq!(sources_entries.len(), 9);
279
280        let entry = release.sources_entry("main")?;
281        assert_eq!(entry.path, "main/source/Sources.xz");
282        assert_eq!(
283            entry.digest,
284            ContentDigest::sha256_hex(
285                "1801d18c1135168d5dd86a8cb85fb5cd5bd81e16174acc25d900dee11389e9cd"
286            )
287            .unwrap()
288        );
289        assert_eq!(entry.size, 8616784);
290        assert_eq!(entry.component, "main");
291        assert_eq!(entry.compression, Compression::Xz);
292
293        let sources = release.resolve_sources("main").await?;
294        assert_eq!(sources.len(), 30952);
295
296        let source = sources.iter().next().unwrap();
297        assert_eq!(source.binary().unwrap().collect::<Vec<_>>(), vec!["0ad"]);
298        assert_eq!(source.version_str()?, "0.0.23.1-5");
299
300        // Make sure field extraction works.
301        for source in sources.iter() {
302            source.required_field_str("Package")?;
303            source.required_field_str("Directory")?;
304            source.format()?;
305            source.version()?;
306            source.maintainer()?;
307            source.package_dependency_fields()?;
308            if let Some(packages) = source.package_list() {
309                for p in packages {
310                    p?;
311                }
312            }
313            if let Some(entries) = source.checksums_sha1() {
314                for entry in entries {
315                    entry?;
316                }
317            }
318            if let Some(entries) = source.checksums_sha256() {
319                for entry in entries {
320                    entry?;
321                }
322            }
323            for entry in source.files()? {
324                entry?;
325            }
326            for fetch in source.file_fetches(ChecksumType::Sha256)? {
327                fetch?;
328            }
329        }
330
331        let controls = sources
332            .iter_with_package_name("libzstd".to_string())
333            .collect::<Vec<_>>();
334        assert_eq!(controls.len(), 1);
335
336        let controls = sources
337            .iter_with_binary_package("zstd".to_string())
338            .collect::<Vec<_>>();
339        assert_eq!(controls.len(), 1);
340
341        let controls = sources
342            .iter_with_architecture("amd64".to_string())
343            .collect::<Vec<_>>();
344        assert_eq!(controls.len(), 297);
345
346        Ok(())
347    }
348
349    #[tokio::test]
350    async fn bullseye_contents() -> Result<()> {
351        let root = HttpRepositoryClient::new(BULLSEYE_URL)?;
352
353        let release = root.release_reader("bullseye").await?;
354
355        let contents_entries = release.contents_indices_entries()?;
356        assert_eq!(contents_entries.len(), 126);
357
358        let contents = release
359            .resolve_contents(Some("contrib"), "all", false)
360            .await?;
361
362        let packages = contents
363            .packages_with_path("etc/cron.d/zfs-auto-snapshot")
364            .collect::<Vec<_>>();
365        assert_eq!(packages, vec!["contrib/utils/zfs-auto-snapshot"]);
366
367        let paths = contents
368            .package_paths("contrib/utils/zfs-auto-snapshot")
369            .collect::<Vec<_>>();
370        assert_eq!(
371            paths,
372            vec![
373                "etc/cron.d/zfs-auto-snapshot",
374                "etc/cron.daily/zfs-auto-snapshot",
375                "etc/cron.hourly/zfs-auto-snapshot",
376                "etc/cron.monthly/zfs-auto-snapshot",
377                "etc/cron.weekly/zfs-auto-snapshot",
378                "usr/sbin/zfs-auto-snapshot",
379                "usr/share/doc/zfs-auto-snapshot/changelog.Debian.gz",
380                "usr/share/doc/zfs-auto-snapshot/copyright",
381                "usr/share/man/man8/zfs-auto-snapshot.8.gz"
382            ]
383        );
384
385        Ok(())
386    }
387}