Skip to main content

debian_workbench/
debhelper.rs

1//! Debhelper utilities.
2use debversion::Version;
3use std::path::Path;
4
5/// Parse the debhelper compat level from a string.
6fn parse_debhelper_compat(s: &str) -> Option<u8> {
7    s.split_once('#').map_or(s, |s| s.0).trim().parse().ok()
8}
9
10/// Read a debian/compat file.
11///
12/// # Arguments
13/// * `path` - The path to the debian/compat file.
14pub fn read_debhelper_compat_file(path: &Path) -> Result<Option<u8>, std::io::Error> {
15    match std::fs::read_to_string(path) {
16        Ok(content) => Ok(parse_debhelper_compat(&content)),
17        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
18        Err(e) => Err(e),
19    }
20}
21
22/// Retrieve the debhelper compat level from a debian/control file.
23///
24/// # Arguments
25/// * `control` - The debian/control file.
26///
27/// # Returns
28/// The debhelper compat level.
29pub fn get_debhelper_compat_level_from_control(control: &debian_control::Control) -> Option<u8> {
30    let source = control.source()?;
31
32    if let Some(dh_compat) = source.as_deb822().get("X-DH-Compat") {
33        return parse_debhelper_compat(dh_compat.as_str());
34    }
35
36    let build_depends = source.build_depends()?;
37
38    let rels = build_depends
39        .entries()
40        .flat_map(|entry| entry.relations().collect::<Vec<_>>())
41        .find(|r| r.name() == "debhelper-compat");
42
43    rels.and_then(|r| r.version().and_then(|v| v.1.to_string().parse().ok()))
44}
45
46/// Retrieve the debhelper compat level from a debian/compat file or debian/control file.
47///
48/// # Arguments
49/// * `path` - The path to the debian/ directory.
50///
51/// # Returns
52/// The debhelper compat level.
53pub fn get_debhelper_compat_level(path: &Path) -> Result<Option<u8>, std::io::Error> {
54    match read_debhelper_compat_file(&path.join("debian/compat")) {
55        Ok(Some(level)) => {
56            return Ok(Some(level));
57        }
58        Err(e) => {
59            return Err(e);
60        }
61        Ok(None) => {}
62    }
63
64    let p = path.join("debian/control");
65
66    match std::fs::File::open(p) {
67        Ok(f) => {
68            let control = debian_control::Control::read_relaxed(f).unwrap().0;
69            Ok(get_debhelper_compat_level_from_control(&control))
70        }
71        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
72        Err(e) => Err(e),
73    }
74}
75
76/// Ask dh_assistant for the supported compat levels.
77///
78/// Cache the result.
79fn get_lintian_compat_levels() -> &'static SupportedCompatLevels {
80    lazy_static::lazy_static! {
81        static ref LINTIAN_COMPAT_LEVELS: SupportedCompatLevels = {
82            // TODO(jelmer): ideally we should be getting these numbers from the compat-release
83            // dh_assistant, rather than what's on the system
84            let output = std::process::Command::new("dh_assistant")
85                .arg("supported-compat-levels")
86                .output()
87                .expect("failed to run dh_assistant")
88                .stdout;
89            serde_json::from_slice(&output).expect("failed to parse dh_assistant output")
90        };
91    };
92    &LINTIAN_COMPAT_LEVELS
93}
94
95#[derive(Debug, serde::Deserialize)]
96#[allow(dead_code)]
97struct SupportedCompatLevels {
98    #[serde(rename = "HIGHEST_STABLE_COMPAT_LEVEL")]
99    highest_stable_compat_level: u8,
100    #[serde(rename = "LOWEST_NON_DEPRECATED_COMPAT_LEVEL")]
101    lowest_non_deprecated_compat_level: u8,
102    #[serde(rename = "LOWEST_VIRTUAL_DEBHELPER_COMPAT_LEVEL")]
103    lowest_virtual_debhelper_compat_level: u8,
104    #[serde(rename = "MAX_COMPAT_LEVEL")]
105    max_compat_level: u8,
106    #[serde(rename = "MIN_COMPAT_LEVEL")]
107    min_compat_level: u8,
108    #[serde(rename = "MIN_COMPAT_LEVEL_NOT_SCHEDULED_FOR_REMOVAL")]
109    min_compat_level_not_scheduled_for_removal: u8,
110}
111
112/// Find the lowest non-deprecated debhelper compat level.
113pub fn lowest_non_deprecated_compat_level() -> u8 {
114    get_lintian_compat_levels().lowest_non_deprecated_compat_level
115}
116
117/// Find the highest stable debhelper compat level.
118pub fn highest_stable_compat_level() -> u8 {
119    get_lintian_compat_levels().highest_stable_compat_level
120}
121
122/// Error type for ensure_minimum_debhelper_version
123#[derive(Debug, Clone, PartialEq, Eq)]
124pub enum EnsureDebhelperError {
125    /// debhelper or debhelper-compat found in Build-Depends-Indep or Build-Depends-Arch
126    DebhelperInWrongField(String),
127    /// Complex rule for debhelper-compat (multiple alternatives or non-equal version)
128    ComplexDebhelperCompatRule,
129    /// debhelper-compat without version constraint
130    DebhelperCompatWithoutVersion,
131}
132
133impl std::fmt::Display for EnsureDebhelperError {
134    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135        match self {
136            EnsureDebhelperError::DebhelperInWrongField(field) => {
137                write!(f, "debhelper in {}", field)
138            }
139            EnsureDebhelperError::ComplexDebhelperCompatRule => {
140                write!(f, "Complex rule for debhelper-compat, aborting")
141            }
142            EnsureDebhelperError::DebhelperCompatWithoutVersion => {
143                write!(f, "debhelper-compat without version, aborting")
144            }
145        }
146    }
147}
148
149impl std::error::Error for EnsureDebhelperError {}
150
151/// Ensure that the package is at least using a specific version of debhelper.
152///
153/// This is a dedicated helper, since debhelper can now also be pulled in
154/// with a debhelper-compat dependency.
155///
156/// # Arguments
157/// * `source` - The source paragraph from debian/control
158/// * `minimum_version` - The minimum version required
159///
160/// # Returns
161/// Ok(true) if the Build-Depends field was modified,
162/// Ok(false) if no change was needed,
163/// Err(EnsureDebhelperError) if there was an error
164///
165/// # Examples
166/// ```rust
167/// use debian_workbench::debhelper::ensure_minimum_debhelper_version;
168///
169/// let text = "Source: foo\nBuild-Depends: debhelper (>= 10)\n";
170/// let mut control = debian_control::Control::read_relaxed(text.as_bytes()).unwrap().0;
171/// let mut source = control.source().unwrap();
172/// let changed = ensure_minimum_debhelper_version(&mut source, &"11".parse().unwrap()).unwrap();
173/// assert!(changed);
174/// assert_eq!(source.build_depends().unwrap().to_string(), "debhelper (>= 11)");
175/// ```
176pub fn ensure_minimum_debhelper_version(
177    source: &mut debian_control::lossless::Source,
178    minimum_version: &Version,
179) -> Result<bool, EnsureDebhelperError> {
180    // Check that debhelper is not in Build-Depends-Indep or Build-Depends-Arch
181    for (field_name, rels_opt) in [
182        ("Build-Depends-Arch", source.build_depends_arch()),
183        ("Build-Depends-Indep", source.build_depends_indep()),
184    ] {
185        let Some(rels) = rels_opt else {
186            continue;
187        };
188
189        for entry in rels.entries() {
190            for rel in entry.relations() {
191                if rel.name() == "debhelper-compat" || rel.name() == "debhelper" {
192                    return Err(EnsureDebhelperError::DebhelperInWrongField(
193                        field_name.to_string(),
194                    ));
195                }
196            }
197        }
198    }
199
200    let mut rels = source.build_depends().unwrap_or_default();
201
202    // Check if debhelper-compat is present
203    for entry in rels.entries() {
204        let has_debhelper_compat = entry
205            .relations()
206            .any(|rel| rel.name() == "debhelper-compat");
207
208        if !has_debhelper_compat {
209            continue;
210        }
211
212        if entry.relations().count() > 1 {
213            return Err(EnsureDebhelperError::ComplexDebhelperCompatRule);
214        }
215
216        let rel = entry.relations().next().unwrap();
217        let Some((constraint, version)) = rel.version() else {
218            return Err(EnsureDebhelperError::DebhelperCompatWithoutVersion);
219        };
220
221        if constraint != debian_control::relations::VersionConstraint::Equal {
222            return Err(EnsureDebhelperError::ComplexDebhelperCompatRule);
223        }
224
225        if &version >= minimum_version {
226            return Ok(false);
227        }
228    }
229
230    // Update or add debhelper dependency
231    let changed = crate::relations::ensure_minimum_version(&mut rels, "debhelper", minimum_version);
232
233    if changed {
234        source.set_build_depends(&rels);
235    }
236
237    Ok(changed)
238}
239
240/// Get the debhelper sequences from Build-Depends.
241///
242/// Extracts all dh-sequence-* packages from the Build-Depends field.
243///
244/// # Arguments
245/// * `source` - The source paragraph from debian/control
246///
247/// # Returns
248/// An iterator over sequence names (without the "dh-sequence-" prefix)
249///
250/// # Examples
251/// ```rust
252/// use debian_workbench::debhelper::get_sequences;
253///
254/// let text = "Source: foo\nBuild-Depends: dh-sequence-python3, dh-sequence-nodejs\n";
255/// let control = debian_control::Control::read_relaxed(text.as_bytes()).unwrap().0;
256/// let source = control.source().unwrap();
257/// let sequences: Vec<String> = get_sequences(&source).collect();
258/// assert_eq!(sequences, vec!["python3", "nodejs"]);
259/// ```
260pub fn get_sequences(source: &debian_control::lossless::Source) -> impl Iterator<Item = String> {
261    let build_depends = source.build_depends().unwrap_or_default();
262
263    build_depends
264        .entries()
265        .flat_map(|entry| entry.relations().collect::<Vec<_>>())
266        .filter_map(|rel| {
267            let name = rel.name();
268            if name.starts_with("dh-sequence-") {
269                Some(name[12..].to_string())
270            } else {
271                None
272            }
273        })
274        .collect::<Vec<_>>()
275        .into_iter()
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    #[test]
283    fn test_parse_debhelper_compat() {
284        assert_eq!(super::parse_debhelper_compat("9"), Some(9));
285        assert_eq!(super::parse_debhelper_compat("9 # comment"), Some(9));
286        assert_eq!(
287            super::parse_debhelper_compat("9 # comment # comment"),
288            Some(9)
289        );
290        assert_eq!(super::parse_debhelper_compat(""), None);
291        assert_eq!(super::parse_debhelper_compat(" # comment"), None);
292    }
293
294    #[test]
295    fn test_get_debhelper_compat_level_from_control() {
296        let text = "Source: foo
297Build-Depends: debhelper-compat (= 9)
298
299Package: foo
300Architecture: any
301";
302
303        let control = debian_control::Control::read_relaxed(&mut text.as_bytes())
304            .unwrap()
305            .0;
306
307        assert_eq!(
308            super::get_debhelper_compat_level_from_control(&control),
309            Some(9)
310        );
311    }
312
313    #[test]
314    fn test_get_debhelper_compat_level_from_control_x_dh_compat() {
315        let text = "Source: foo
316X-DH-Compat: 9
317Build-Depends: debhelper
318";
319
320        let control = debian_control::Control::read_relaxed(&mut text.as_bytes())
321            .unwrap()
322            .0;
323
324        assert_eq!(
325            super::get_debhelper_compat_level_from_control(&control),
326            Some(9)
327        );
328    }
329
330    mod ensure_minimum_debhelper_version_tests {
331        use super::*;
332
333        #[test]
334        fn test_already() {
335            let text = "Source: foo\nBuild-Depends: debhelper (>= 10)\n";
336            let control = debian_control::Control::read_relaxed(text.as_bytes())
337                .unwrap()
338                .0;
339            let mut source = control.source().unwrap();
340
341            assert!(
342                !ensure_minimum_debhelper_version(&mut source, &"10".parse().unwrap()).unwrap()
343            );
344            assert_eq!(
345                source.build_depends().unwrap().to_string(),
346                "debhelper (>= 10)"
347            );
348
349            assert!(!ensure_minimum_debhelper_version(&mut source, &"9".parse().unwrap()).unwrap());
350            assert_eq!(
351                source.build_depends().unwrap().to_string(),
352                "debhelper (>= 10)"
353            );
354        }
355
356        #[test]
357        fn test_already_compat() {
358            let text = "Source: foo\nBuild-Depends: debhelper-compat (= 10)\n";
359            let control = debian_control::Control::read_relaxed(text.as_bytes())
360                .unwrap()
361                .0;
362            let mut source = control.source().unwrap();
363
364            assert!(
365                !ensure_minimum_debhelper_version(&mut source, &"10".parse().unwrap()).unwrap()
366            );
367            assert_eq!(
368                source.build_depends().unwrap().to_string(),
369                "debhelper-compat (= 10)"
370            );
371
372            assert!(!ensure_minimum_debhelper_version(&mut source, &"9".parse().unwrap()).unwrap());
373            assert_eq!(
374                source.build_depends().unwrap().to_string(),
375                "debhelper-compat (= 10)"
376            );
377        }
378
379        #[test]
380        fn test_bump() {
381            let text = "Source: foo\nBuild-Depends: debhelper (>= 10)\n";
382            let control = debian_control::Control::read_relaxed(text.as_bytes())
383                .unwrap()
384                .0;
385            let mut source = control.source().unwrap();
386
387            assert!(ensure_minimum_debhelper_version(&mut source, &"11".parse().unwrap()).unwrap());
388            assert_eq!(
389                source.build_depends().unwrap().to_string(),
390                "debhelper (>= 11)"
391            );
392        }
393
394        #[test]
395        fn test_bump_compat() {
396            let text = "Source: foo\nBuild-Depends: debhelper-compat (= 10)\n";
397            let control = debian_control::Control::read_relaxed(text.as_bytes())
398                .unwrap()
399                .0;
400            let mut source = control.source().unwrap();
401
402            assert!(ensure_minimum_debhelper_version(&mut source, &"11".parse().unwrap()).unwrap());
403            assert_eq!(
404                source.build_depends().unwrap().to_string(),
405                "debhelper (>= 11), debhelper-compat (= 10)"
406            );
407
408            assert!(
409                ensure_minimum_debhelper_version(&mut source, &"11.1".parse().unwrap()).unwrap()
410            );
411            assert_eq!(
412                source.build_depends().unwrap().to_string(),
413                "debhelper (>= 11.1), debhelper-compat (= 10)"
414            );
415        }
416
417        #[test]
418        fn test_not_set() {
419            let text = "Source: foo\n";
420            let control = debian_control::Control::read_relaxed(text.as_bytes())
421                .unwrap()
422                .0;
423            let mut source = control.source().unwrap();
424
425            assert!(ensure_minimum_debhelper_version(&mut source, &"10".parse().unwrap()).unwrap());
426            assert_eq!(
427                source.build_depends().unwrap().to_string(),
428                "debhelper (>= 10)"
429            );
430        }
431
432        #[test]
433        fn test_in_indep() {
434            let text = "Source: foo\nBuild-Depends-Indep: debhelper (>= 9)\n";
435            let control = debian_control::Control::read_relaxed(text.as_bytes())
436                .unwrap()
437                .0;
438            let mut source = control.source().unwrap();
439
440            let result = ensure_minimum_debhelper_version(&mut source, &"10".parse().unwrap());
441            assert!(result.is_err());
442            assert_eq!(
443                result.unwrap_err(),
444                EnsureDebhelperError::DebhelperInWrongField("Build-Depends-Indep".to_string())
445            );
446        }
447    }
448
449    mod get_sequences_tests {
450        use super::*;
451
452        #[test]
453        fn test_no_sequences() {
454            let text = "Source: foo\nBuild-Depends: debhelper (>= 10)\n";
455            let control = debian_control::Control::read_relaxed(text.as_bytes())
456                .unwrap()
457                .0;
458            let source = control.source().unwrap();
459
460            let sequences: Vec<String> = get_sequences(&source).collect();
461            assert_eq!(sequences, Vec::<String>::new());
462        }
463
464        #[test]
465        fn test_single_sequence() {
466            let text = "Source: foo\nBuild-Depends: dh-sequence-python3, debhelper (>= 10)\n";
467            let control = debian_control::Control::read_relaxed(text.as_bytes())
468                .unwrap()
469                .0;
470            let source = control.source().unwrap();
471
472            let sequences: Vec<String> = get_sequences(&source).collect();
473            assert_eq!(sequences, vec!["python3"]);
474        }
475
476        #[test]
477        fn test_multiple_sequences() {
478            let text = "Source: foo\nBuild-Depends: dh-sequence-python3, dh-sequence-nodejs, debhelper (>= 10)\n";
479            let control = debian_control::Control::read_relaxed(text.as_bytes())
480                .unwrap()
481                .0;
482            let source = control.source().unwrap();
483
484            let sequences: Vec<String> = get_sequences(&source).collect();
485            assert_eq!(sequences, vec!["python3", "nodejs"]);
486        }
487
488        #[test]
489        fn test_no_build_depends() {
490            let text = "Source: foo\n";
491            let control = debian_control::Control::read_relaxed(text.as_bytes())
492                .unwrap()
493                .0;
494            let source = control.source().unwrap();
495
496            let sequences: Vec<String> = get_sequences(&source).collect();
497            assert_eq!(sequences, Vec::<String>::new());
498        }
499    }
500}