tugger_debian/
deb.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/*! Interfaces for .deb package files.
6
7The .deb file specification lives at https://manpages.debian.org/unstable/dpkg-dev/deb.5.en.html.
8*/
9
10use {
11    crate::ControlFile,
12    os_str_bytes::OsStrBytes,
13    std::{
14        io::{BufWriter, Cursor, Read, Write},
15        path::Path,
16        time::SystemTime,
17    },
18    tugger_file_manifest::{FileEntry, FileManifest, FileManifestError},
19};
20
21/// Represents an error related to .deb file handling.
22#[derive(Debug)]
23pub enum DebError {
24    IoError(std::io::Error),
25    PathError(String),
26    FileManifestError(FileManifestError),
27}
28
29impl std::fmt::Display for DebError {
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        match self {
32            Self::IoError(inner) => write!(f, "I/O error: {}", inner),
33            Self::PathError(msg) => write!(f, "path error: {}", msg),
34            Self::FileManifestError(inner) => write!(f, "file manifest error: {}", inner),
35        }
36    }
37}
38
39impl std::error::Error for DebError {}
40
41impl From<std::io::Error> for DebError {
42    fn from(e: std::io::Error) -> Self {
43        Self::IoError(e)
44    }
45}
46
47impl<W> From<std::io::IntoInnerError<W>> for DebError {
48    fn from(e: std::io::IntoInnerError<W>) -> Self {
49        Self::IoError(e.into())
50    }
51}
52
53impl From<FileManifestError> for DebError {
54    fn from(e: FileManifestError) -> Self {
55        Self::FileManifestError(e)
56    }
57}
58
59/// Compression format to apply to `.deb` files.
60pub enum DebCompression {
61    /// Do not compress contents of `.deb` files.
62    Uncompressed,
63    /// Compress as `.gz` files.
64    Gzip,
65    /// Compress as `.xz` files using a specified compression level.
66    Xz(u32),
67    /// Compress as `.zst` files using a specified compression level.
68    Zstandard(i32),
69}
70
71impl DebCompression {
72    /// Obtain the filename extension for this compression format.
73    pub fn extension(&self) -> &'static str {
74        match self {
75            Self::Uncompressed => "",
76            Self::Gzip => ".gz",
77            Self::Xz(_) => ".xz",
78            Self::Zstandard(_) => ".zst",
79        }
80    }
81
82    /// Compress input data from a reader.
83    pub fn compress(&self, reader: &mut impl Read) -> Result<Vec<u8>, DebError> {
84        let mut buffer = vec![];
85
86        match self {
87            Self::Uncompressed => {
88                std::io::copy(reader, &mut buffer)?;
89            }
90            Self::Gzip => {
91                let header = libflate::gzip::HeaderBuilder::new().finish();
92
93                let mut encoder = libflate::gzip::Encoder::with_options(
94                    &mut buffer,
95                    libflate::gzip::EncodeOptions::new().header(header),
96                )?;
97                std::io::copy(reader, &mut encoder)?;
98                encoder.finish().into_result()?;
99            }
100            Self::Xz(level) => {
101                let mut encoder = xz2::write::XzEncoder::new(buffer, *level);
102                std::io::copy(reader, &mut encoder)?;
103                buffer = encoder.finish()?;
104            }
105            Self::Zstandard(level) => {
106                let mut encoder = zstd::Encoder::new(buffer, *level)?;
107                std::io::copy(reader, &mut encoder)?;
108                buffer = encoder.finish()?;
109            }
110        }
111
112        Ok(buffer)
113    }
114}
115
116/// A builder for a `.deb` package file.
117pub struct DebBuilder<'control> {
118    control_builder: ControlTarBuilder<'control>,
119
120    compression: DebCompression,
121
122    /// Files to install as part of the package.
123    install_files: FileManifest,
124
125    mtime: Option<SystemTime>,
126}
127
128impl<'control> DebBuilder<'control> {
129    /// Construct a new instance using a control file.
130    pub fn new(control_file: ControlFile<'control>) -> Self {
131        Self {
132            control_builder: ControlTarBuilder::new(control_file),
133            compression: DebCompression::Gzip,
134            install_files: FileManifest::default(),
135            mtime: None,
136        }
137    }
138
139    /// Set the compression format to use.
140    ///
141    /// Not all compression formats are supported by all Linux distributions.
142    pub fn set_compression(mut self, compression: DebCompression) -> Self {
143        self.compression = compression;
144        self
145    }
146
147    fn mtime(&self) -> u64 {
148        self.mtime
149            .unwrap_or_else(std::time::SystemTime::now)
150            .duration_since(std::time::UNIX_EPOCH)
151            .expect("times before UNIX epoch not accepted")
152            .as_secs()
153    }
154
155    /// Set the modified time to use on archive members.
156    ///
157    /// If this is called, all archive members will use the specified time, helping
158    /// to make archive content deterministic.
159    ///
160    /// If not called, the current time will be used.
161    pub fn set_mtime(mut self, time: Option<SystemTime>) -> Self {
162        self.mtime = time;
163        self.control_builder = self.control_builder.set_mtime(time);
164        self
165    }
166
167    /// Add an extra file to the `control.tar` archive.
168    pub fn extra_control_tar_file(
169        mut self,
170        path: impl AsRef<Path>,
171        entry: impl Into<FileEntry>,
172    ) -> Result<Self, DebError> {
173        self.control_builder = self.control_builder.add_extra_file(path, entry)?;
174        Ok(self)
175    }
176
177    /// Register a file as to be installed by this package.
178    ///
179    /// Filenames should be relative to the filesystem root. e.g.
180    /// `usr/bin/myapp`.
181    ///
182    /// The file content will be added to the `data.tar` archive and registered with
183    /// the `control.tar` archive so its checksum is computed.
184    pub fn install_file(
185        mut self,
186        path: impl AsRef<Path> + Clone,
187        entry: impl Into<FileEntry> + Clone,
188    ) -> Result<Self, DebError> {
189        let entry = entry.into();
190
191        let data = entry.resolve_content()?;
192        let mut cursor = Cursor::new(&data);
193        self.control_builder = self
194            .control_builder
195            .add_data_file(path.clone(), &mut cursor)?;
196
197        self.install_files.add_file_entry(path, entry)?;
198
199        Ok(self)
200    }
201
202    /// Write `.deb` file content to a writer.
203    ///
204    /// This effectively materialized the `.deb` package somewhere.
205    pub fn write<W: Write>(&self, writer: &mut W) -> Result<(), DebError> {
206        let mut ar_builder = ar::Builder::new(writer);
207
208        // First entry is a debian-binary file with static content.
209        let data: &[u8] = b"2.0\n";
210        let mut header = ar::Header::new(b"debian-binary".to_vec(), data.len() as _);
211        header.set_mode(0o644);
212        header.set_mtime(self.mtime());
213        header.set_uid(0);
214        header.set_gid(0);
215        ar_builder.append(&header, data)?;
216
217        // Second entry is a control.tar with metadata.
218        let mut control_writer = BufWriter::new(Vec::new());
219        self.control_builder.write(&mut control_writer)?;
220        let control_tar = control_writer.into_inner()?;
221        let control_tar = self
222            .compression
223            .compress(&mut std::io::Cursor::new(control_tar))?;
224
225        let mut header = ar::Header::new(
226            format!("control.tar{}", self.compression.extension()).into_bytes(),
227            control_tar.len() as _,
228        );
229        header.set_mode(0o644);
230        header.set_mtime(self.mtime());
231        header.set_uid(0);
232        header.set_gid(0);
233        ar_builder.append(&header, &*control_tar)?;
234
235        // Third entry is a data.tar with file content.
236        let mut data_writer = BufWriter::new(Vec::new());
237        write_deb_tar(&mut data_writer, &self.install_files, self.mtime())?;
238        let data_tar = data_writer.into_inner()?;
239        let data_tar = self
240            .compression
241            .compress(&mut std::io::Cursor::new(data_tar))?;
242
243        let mut header = ar::Header::new(
244            format!("data.tar{}", self.compression.extension()).into_bytes(),
245            data_tar.len() as _,
246        );
247        header.set_mode(0o644);
248        header.set_mtime(self.mtime());
249        header.set_uid(0);
250        header.set_gid(0);
251        ar_builder.append(&header, &*data_tar)?;
252
253        Ok(())
254    }
255}
256
257fn new_tar_header(mtime: u64) -> Result<tar::Header, DebError> {
258    let mut header = tar::Header::new_gnu();
259    header.set_uid(0);
260    header.set_gid(0);
261    header.set_username("root")?;
262    header.set_groupname("root")?;
263    header.set_mtime(mtime);
264
265    Ok(header)
266}
267
268fn set_header_path(
269    builder: &mut tar::Builder<impl Write>,
270    header: &mut tar::Header,
271    path: &Path,
272    is_directory: bool,
273) -> Result<(), DebError> {
274    // Debian archives in the wild have filenames beginning with `./`. And
275    // paths ending with `/` are directories. However, we cannot call
276    // `header.set_path()` with `./` on anything except the root directory
277    // because it will normalize away the `./` bit. So we set the header field
278    // directly when adding directories and files.
279
280    // We should only be dealing with GNU headers, which simplifies our code a bit.
281    assert!(header.as_ustar().is_none());
282
283    let value = format!(
284        "./{}{}",
285        path.display(),
286        if is_directory { "/" } else { "" }
287    );
288    let value_bytes = value.as_bytes();
289
290    let name_buffer = &mut header.as_old_mut().name;
291
292    // If it fits within the buffer, copy it over.
293    if value_bytes.len() <= name_buffer.len() {
294        name_buffer[0..value_bytes.len()].copy_from_slice(value_bytes);
295    } else {
296        // Else we emit a special entry to extend the filename. Who knew tar
297        // files were this jank.
298        let mut header2 = tar::Header::new_gnu();
299        let name = b"././@LongLink";
300        header2.as_gnu_mut().unwrap().name[..name.len()].clone_from_slice(&name[..]);
301        header2.set_mode(0o644);
302        header2.set_uid(0);
303        header2.set_gid(0);
304        header2.set_mtime(0);
305        header2.set_size(value_bytes.len() as u64 + 1);
306        header2.set_entry_type(tar::EntryType::new(b'L'));
307        header2.set_cksum();
308        let mut data = value_bytes.chain(std::io::repeat(0).take(1));
309        builder.append(&header2, &mut data)?;
310
311        let truncated_bytes = &value_bytes[0..name_buffer.len()];
312        name_buffer[0..truncated_bytes.len()].copy_from_slice(truncated_bytes);
313    }
314
315    Ok(())
316}
317
318/// A builder for a `control.tar` file inside `.deb` packages.
319pub struct ControlTarBuilder<'a> {
320    /// The file that will become the `control` file.
321    control: ControlFile<'a>,
322    /// Extra maintainer scripts to install.
323    extra_files: FileManifest,
324    /// Hashes of files that will be installed.
325    md5sums: Vec<Vec<u8>>,
326    /// Modified time for tar archive entries.
327    mtime: Option<SystemTime>,
328}
329
330impl<'a> ControlTarBuilder<'a> {
331    /// Create a new instance from a control file.
332    pub fn new(control_file: ControlFile<'a>) -> Self {
333        Self {
334            control: control_file,
335            extra_files: FileManifest::default(),
336            md5sums: vec![],
337            mtime: None,
338        }
339    }
340
341    /// Add an extra file to the control archive.
342    ///
343    /// This is usually used to add maintainer scripts. Maintainer scripts
344    /// are special scripts like `preinst` and `postrm` that are executed
345    /// during certain activities.
346    pub fn add_extra_file(
347        mut self,
348        path: impl AsRef<Path>,
349        entry: impl Into<FileEntry>,
350    ) -> Result<Self, DebError> {
351        self.extra_files.add_file_entry(path, entry)?;
352
353        Ok(self)
354    }
355
356    /// Add a data file to be indexed.
357    ///
358    /// This should be called for every file in the corresponding `data.tar`
359    /// archive in the `.deb` archive.
360    ///
361    /// `path` is the relative path the file will be installed to.
362    /// `reader` is a reader to obtain the file content.
363    ///
364    /// This method has the side-effect of computing the checksum for the path
365    /// so a checksums entry can be written.
366    pub fn add_data_file<P: AsRef<Path>, R: Read>(
367        mut self,
368        path: P,
369        reader: &mut R,
370    ) -> Result<Self, DebError> {
371        let mut context = md5::Context::new();
372
373        let mut buffer = [0; 32768];
374
375        loop {
376            let read = reader.read(&mut buffer)?;
377            if read == 0 {
378                break;
379            }
380
381            context.consume(&buffer[0..read]);
382        }
383
384        let digest = context.compute();
385
386        let mut entry = Vec::new();
387        entry.write_all(&digest.to_ascii_lowercase())?;
388        entry.write_all(b"  ")?;
389        entry.write_all(path.as_ref().to_raw_bytes().as_ref())?;
390        entry.write_all(b"\n")?;
391
392        self.md5sums.push(entry);
393
394        Ok(self)
395    }
396
397    fn mtime(&self) -> u64 {
398        self.mtime
399            .unwrap_or_else(std::time::SystemTime::now)
400            .duration_since(std::time::UNIX_EPOCH)
401            .expect("times before UNIX epoch not accepted")
402            .as_secs()
403    }
404
405    pub fn set_mtime(mut self, time: Option<SystemTime>) -> Self {
406        self.mtime = time;
407        self
408    }
409
410    /// Write the `control.tar` file to a writer.
411    pub fn write<W: Write>(&self, writer: &mut W) -> Result<(), DebError> {
412        let mut control_buffer = BufWriter::new(Vec::new());
413        self.control.write(&mut control_buffer)?;
414        let control_data = control_buffer.into_inner()?;
415
416        let mut manifest = self.extra_files.clone();
417        manifest.add_file_entry("control", control_data)?;
418        manifest.add_file_entry("md5sums", self.md5sums.concat::<u8>())?;
419
420        write_deb_tar(writer, &manifest, self.mtime())
421    }
422}
423
424/// Write a tar archive suitable for inclusion in a `.deb` archive.
425pub fn write_deb_tar<W: Write>(
426    writer: W,
427    files: &FileManifest,
428    mtime: u64,
429) -> Result<(), DebError> {
430    let mut builder = tar::Builder::new(writer);
431
432    // Add root directory entry.
433    let mut header = new_tar_header(mtime)?;
434    header.set_path(Path::new("./"))?;
435    header.set_mode(0o755);
436    header.set_size(0);
437    header.set_cksum();
438    builder.append(&header, &*vec![])?;
439
440    // And entries for each directory in the tree.
441    for directory in files.relative_directories() {
442        let mut header = new_tar_header(mtime)?;
443        set_header_path(&mut builder, &mut header, &directory, true)?;
444        header.set_mode(0o755);
445        header.set_size(0);
446        header.set_cksum();
447        builder.append(&header, &*vec![])?;
448    }
449
450    // FileManifest is backed by a BTreeMap, so iteration is deterministic.
451    for (rel_path, content) in files.iter_entries() {
452        let data = content.resolve_content()?;
453
454        let mut header = new_tar_header(mtime)?;
455        set_header_path(&mut builder, &mut header, rel_path, false)?;
456        header.set_mode(if content.is_executable() {
457            0o755
458        } else {
459            0o644
460        });
461        header.set_size(data.len() as _);
462        header.set_cksum();
463        builder.append(&header, &*data)?;
464    }
465
466    builder.finish()?;
467
468    Ok(())
469}
470
471#[cfg(test)]
472mod tests {
473    use {
474        super::*,
475        crate::ControlParagraph,
476        anyhow::{anyhow, Result},
477        std::path::PathBuf,
478    };
479
480    #[test]
481    fn test_write_control_tar_simple() -> Result<()> {
482        let mut control_para = ControlParagraph::default();
483        control_para.add_field_from_string("Package".into(), "mypackage".into())?;
484        control_para.add_field_from_string("Architecture".into(), "amd64".into())?;
485
486        let mut control = ControlFile::default();
487        control.add_paragraph(control_para);
488
489        let builder = ControlTarBuilder::new(control)
490            .set_mtime(Some(SystemTime::UNIX_EPOCH))
491            .add_extra_file("prerm", FileEntry::new_from_data(vec![42], true))?
492            .add_data_file("usr/bin/myapp", &mut std::io::Cursor::new("data"))?;
493
494        let mut buffer = vec![];
495        builder.write(&mut buffer)?;
496
497        let mut archive = tar::Archive::new(std::io::Cursor::new(buffer));
498
499        for (i, entry) in archive.entries()?.enumerate() {
500            let entry = entry?;
501
502            let path = match i {
503                0 => Path::new("./"),
504                1 => Path::new("./control"),
505                2 => Path::new("./md5sums"),
506                3 => Path::new("./prerm"),
507                _ => return Err(anyhow!("unexpected archive entry")),
508            };
509
510            assert_eq!(entry.path()?, path, "entry {} path matches", i);
511        }
512
513        Ok(())
514    }
515
516    #[test]
517    fn test_write_data_tar_one_file() -> Result<()> {
518        let mut manifest = FileManifest::default();
519        manifest.add_file_entry("foo/bar.txt", FileEntry::new_from_data(vec![42], true))?;
520
521        let mut buffer = vec![];
522        write_deb_tar(&mut buffer, &manifest, 2)?;
523
524        let mut archive = tar::Archive::new(std::io::Cursor::new(buffer));
525
526        for (i, entry) in archive.entries()?.enumerate() {
527            let entry = entry?;
528
529            let path = match i {
530                0 => Path::new("./"),
531                1 => Path::new("./foo/"),
532                2 => Path::new("./foo/bar.txt"),
533                _ => return Err(anyhow!("unexpected archive entry")),
534            };
535
536            assert_eq!(entry.path()?, path, "entry {} path matches", i);
537        }
538
539        Ok(())
540    }
541
542    #[test]
543    fn test_write_data_tar_long_path() -> Result<()> {
544        let long_path = PathBuf::from(format!("f{}.txt", "u".repeat(200)));
545
546        let mut manifest = FileManifest::default();
547
548        manifest.add_file_entry(&long_path, vec![42])?;
549
550        let mut buffer = vec![];
551        write_deb_tar(&mut buffer, &manifest, 2)?;
552
553        let mut archive = tar::Archive::new(std::io::Cursor::new(buffer));
554
555        for (i, entry) in archive.entries()?.enumerate() {
556            let entry = entry?;
557
558            if i != 1 {
559                continue;
560            }
561
562            assert_eq!(
563                entry.path()?,
564                Path::new(&format!("./f{}.txt", "u".repeat(200)))
565            );
566        }
567
568        Ok(())
569    }
570
571    #[test]
572    fn test_write_deb() -> Result<()> {
573        let mut control_para = ControlParagraph::default();
574        control_para.add_field_from_string("Package".into(), "mypackage".into())?;
575        control_para.add_field_from_string("Architecture".into(), "amd64".into())?;
576
577        let mut control = ControlFile::default();
578        control.add_paragraph(control_para);
579
580        let builder = DebBuilder::new(control)
581            .set_compression(DebCompression::Zstandard(3))
582            .install_file("usr/bin/myapp", FileEntry::new_from_data(vec![42], true))?;
583
584        let mut buffer = vec![];
585        builder.write(&mut buffer)?;
586
587        let mut archive = ar::Archive::new(std::io::Cursor::new(buffer));
588        {
589            let entry = archive.next_entry().unwrap().unwrap();
590            assert_eq!(entry.header().identifier(), b"debian-binary");
591        }
592        {
593            let entry = archive.next_entry().unwrap().unwrap();
594            assert_eq!(entry.header().identifier(), b"control.tar.zst");
595        }
596        {
597            let entry = archive.next_entry().unwrap().unwrap();
598            assert_eq!(entry.header().identifier(), b"data.tar.zst");
599        }
600
601        assert!(archive.next_entry().is_none());
602
603        Ok(())
604    }
605}