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.try_name().as_deref() == Some("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                let name = rel.try_name();
192                if name.as_deref() == Some("debhelper-compat")
193                    || name.as_deref() == Some("debhelper")
194                {
195                    return Err(EnsureDebhelperError::DebhelperInWrongField(
196                        field_name.to_string(),
197                    ));
198                }
199            }
200        }
201    }
202
203    let mut rels = source.build_depends().unwrap_or_default();
204
205    // Check if debhelper-compat is present
206    for entry in rels.entries() {
207        let has_debhelper_compat = entry
208            .relations()
209            .any(|rel| rel.try_name().as_deref() == Some("debhelper-compat"));
210
211        if !has_debhelper_compat {
212            continue;
213        }
214
215        if entry.relations().count() > 1 {
216            return Err(EnsureDebhelperError::ComplexDebhelperCompatRule);
217        }
218
219        let rel = entry.relations().next().unwrap();
220        let Some((constraint, version)) = rel.version() else {
221            return Err(EnsureDebhelperError::DebhelperCompatWithoutVersion);
222        };
223
224        if constraint != debian_control::relations::VersionConstraint::Equal {
225            return Err(EnsureDebhelperError::ComplexDebhelperCompatRule);
226        }
227
228        if &version >= minimum_version {
229            return Ok(false);
230        }
231    }
232
233    // Update or add debhelper dependency
234    let changed = crate::relations::ensure_minimum_version(&mut rels, "debhelper", minimum_version);
235
236    if changed {
237        source.set_build_depends(&rels);
238    }
239
240    Ok(changed)
241}
242
243/// Get the debhelper sequences from Build-Depends.
244///
245/// Extracts all dh-sequence-* packages from the Build-Depends field.
246///
247/// # Arguments
248/// * `source` - The source paragraph from debian/control
249///
250/// # Returns
251/// An iterator over sequence names (without the "dh-sequence-" prefix)
252///
253/// # Examples
254/// ```rust
255/// use debian_workbench::debhelper::get_sequences;
256///
257/// let text = "Source: foo\nBuild-Depends: dh-sequence-python3, dh-sequence-nodejs\n";
258/// let control = debian_control::Control::read_relaxed(text.as_bytes()).unwrap().0;
259/// let source = control.source().unwrap();
260/// let sequences: Vec<String> = get_sequences(&source).collect();
261/// assert_eq!(sequences, vec!["python3", "nodejs"]);
262/// ```
263pub fn get_sequences(source: &debian_control::lossless::Source) -> impl Iterator<Item = String> {
264    let build_depends = source.build_depends().unwrap_or_default();
265
266    build_depends
267        .entries()
268        .flat_map(|entry| entry.relations().collect::<Vec<_>>())
269        .filter_map(|rel| {
270            let name = rel.try_name()?;
271            name.strip_prefix("dh-sequence-").map(|s| s.to_string())
272        })
273        .collect::<Vec<_>>()
274        .into_iter()
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    #[test]
282    fn test_parse_debhelper_compat() {
283        assert_eq!(super::parse_debhelper_compat("9"), Some(9));
284        assert_eq!(super::parse_debhelper_compat("9 # comment"), Some(9));
285        assert_eq!(
286            super::parse_debhelper_compat("9 # comment # comment"),
287            Some(9)
288        );
289        assert_eq!(super::parse_debhelper_compat(""), None);
290        assert_eq!(super::parse_debhelper_compat(" # comment"), None);
291    }
292
293    #[test]
294    fn test_get_debhelper_compat_level_from_control() {
295        let text = "Source: foo
296Build-Depends: debhelper-compat (= 9)
297
298Package: foo
299Architecture: any
300";
301
302        let control = debian_control::Control::read_relaxed(&mut text.as_bytes())
303            .unwrap()
304            .0;
305
306        assert_eq!(
307            super::get_debhelper_compat_level_from_control(&control),
308            Some(9)
309        );
310    }
311
312    #[test]
313    fn test_get_debhelper_compat_level_from_control_x_dh_compat() {
314        let text = "Source: foo
315X-DH-Compat: 9
316Build-Depends: debhelper
317";
318
319        let control = debian_control::Control::read_relaxed(&mut text.as_bytes())
320            .unwrap()
321            .0;
322
323        assert_eq!(
324            super::get_debhelper_compat_level_from_control(&control),
325            Some(9)
326        );
327    }
328
329    mod ensure_minimum_debhelper_version_tests {
330        use super::*;
331
332        #[test]
333        fn test_already() {
334            let text = "Source: foo\nBuild-Depends: debhelper (>= 10)\n";
335            let control = debian_control::Control::read_relaxed(text.as_bytes())
336                .unwrap()
337                .0;
338            let mut source = control.source().unwrap();
339
340            assert!(
341                !ensure_minimum_debhelper_version(&mut source, &"10".parse().unwrap()).unwrap()
342            );
343            assert_eq!(
344                source.build_depends().unwrap().to_string(),
345                "debhelper (>= 10)"
346            );
347
348            assert!(!ensure_minimum_debhelper_version(&mut source, &"9".parse().unwrap()).unwrap());
349            assert_eq!(
350                source.build_depends().unwrap().to_string(),
351                "debhelper (>= 10)"
352            );
353        }
354
355        #[test]
356        fn test_already_compat() {
357            let text = "Source: foo\nBuild-Depends: debhelper-compat (= 10)\n";
358            let control = debian_control::Control::read_relaxed(text.as_bytes())
359                .unwrap()
360                .0;
361            let mut source = control.source().unwrap();
362
363            assert!(
364                !ensure_minimum_debhelper_version(&mut source, &"10".parse().unwrap()).unwrap()
365            );
366            assert_eq!(
367                source.build_depends().unwrap().to_string(),
368                "debhelper-compat (= 10)"
369            );
370
371            assert!(!ensure_minimum_debhelper_version(&mut source, &"9".parse().unwrap()).unwrap());
372            assert_eq!(
373                source.build_depends().unwrap().to_string(),
374                "debhelper-compat (= 10)"
375            );
376        }
377
378        #[test]
379        fn test_bump() {
380            let text = "Source: foo\nBuild-Depends: debhelper (>= 10)\n";
381            let control = debian_control::Control::read_relaxed(text.as_bytes())
382                .unwrap()
383                .0;
384            let mut source = control.source().unwrap();
385
386            assert!(ensure_minimum_debhelper_version(&mut source, &"11".parse().unwrap()).unwrap());
387            assert_eq!(
388                source.build_depends().unwrap().to_string(),
389                "debhelper (>= 11)"
390            );
391        }
392
393        #[test]
394        fn test_bump_compat() {
395            let text = "Source: foo\nBuild-Depends: debhelper-compat (= 10)\n";
396            let control = debian_control::Control::read_relaxed(text.as_bytes())
397                .unwrap()
398                .0;
399            let mut source = control.source().unwrap();
400
401            assert!(ensure_minimum_debhelper_version(&mut source, &"11".parse().unwrap()).unwrap());
402            assert_eq!(
403                source.build_depends().unwrap().to_string(),
404                "debhelper (>= 11), debhelper-compat (= 10)"
405            );
406
407            assert!(
408                ensure_minimum_debhelper_version(&mut source, &"11.1".parse().unwrap()).unwrap()
409            );
410            assert_eq!(
411                source.build_depends().unwrap().to_string(),
412                "debhelper (>= 11.1), debhelper-compat (= 10)"
413            );
414        }
415
416        #[test]
417        fn test_not_set() {
418            let text = "Source: foo\n";
419            let control = debian_control::Control::read_relaxed(text.as_bytes())
420                .unwrap()
421                .0;
422            let mut source = control.source().unwrap();
423
424            assert!(ensure_minimum_debhelper_version(&mut source, &"10".parse().unwrap()).unwrap());
425            assert_eq!(
426                source.build_depends().unwrap().to_string(),
427                "debhelper (>= 10)"
428            );
429        }
430
431        #[test]
432        fn test_in_indep() {
433            let text = "Source: foo\nBuild-Depends-Indep: debhelper (>= 9)\n";
434            let control = debian_control::Control::read_relaxed(text.as_bytes())
435                .unwrap()
436                .0;
437            let mut source = control.source().unwrap();
438
439            let result = ensure_minimum_debhelper_version(&mut source, &"10".parse().unwrap());
440            assert!(result.is_err());
441            assert_eq!(
442                result.unwrap_err(),
443                EnsureDebhelperError::DebhelperInWrongField("Build-Depends-Indep".to_string())
444            );
445        }
446    }
447
448    mod get_sequences_tests {
449        use super::*;
450
451        #[test]
452        fn test_no_sequences() {
453            let text = "Source: foo\nBuild-Depends: debhelper (>= 10)\n";
454            let control = debian_control::Control::read_relaxed(text.as_bytes())
455                .unwrap()
456                .0;
457            let source = control.source().unwrap();
458
459            let sequences: Vec<String> = get_sequences(&source).collect();
460            assert_eq!(sequences, Vec::<String>::new());
461        }
462
463        #[test]
464        fn test_single_sequence() {
465            let text = "Source: foo\nBuild-Depends: dh-sequence-python3, debhelper (>= 10)\n";
466            let control = debian_control::Control::read_relaxed(text.as_bytes())
467                .unwrap()
468                .0;
469            let source = control.source().unwrap();
470
471            let sequences: Vec<String> = get_sequences(&source).collect();
472            assert_eq!(sequences, vec!["python3"]);
473        }
474
475        #[test]
476        fn test_multiple_sequences() {
477            let text = "Source: foo\nBuild-Depends: dh-sequence-python3, dh-sequence-nodejs, debhelper (>= 10)\n";
478            let control = debian_control::Control::read_relaxed(text.as_bytes())
479                .unwrap()
480                .0;
481            let source = control.source().unwrap();
482
483            let sequences: Vec<String> = get_sequences(&source).collect();
484            assert_eq!(sequences, vec!["python3", "nodejs"]);
485        }
486
487        #[test]
488        fn test_no_build_depends() {
489            let text = "Source: foo\n";
490            let control = debian_control::Control::read_relaxed(text.as_bytes())
491                .unwrap()
492                .0;
493            let source = control.source().unwrap();
494
495            let sequences: Vec<String> = get_sequences(&source).collect();
496            assert_eq!(sequences, Vec::<String>::new());
497        }
498    }
499}