Skip to main content

linux_targets/
lib.rs

1//! Linux target selection shared by the rustcommons toolchain.
2//!
3//! A target is chosen in one of two ways:
4//!
5//! - **A distribution preset** (`--distro sles-12-sp5`): supplies the glibc
6//!   floor, the kernel floor, and a container image with the real userland.
7//! - **Explicit floors** (`--glibc 2.22`, `--kernel 4.12`, or a versioned
8//!   target such as `x86_64-unknown-linux-gnu.2.22`): each is optional. A
9//!   missing kernel floor means the kernel is **not simulated**: programs run
10//!   against the host kernel.
11//!
12//! A preset cannot be combined with explicit floors: "SLES 12 SP5 with glibc
13//! 2.28" is not a real system. [`Selection::resolve`] enforces these rules.
14//!
15//! ```
16//! use linux_targets::{Release, Request, Selection};
17//!
18//! // A preset sets both floors and a test image.
19//! let sles = Selection::resolve(&Request { distro: Some("sles-12-sp5"), ..Default::default() })?;
20//! assert_eq!(sles.glibc, Some(Release::parse("2.22")?));
21//! assert_eq!(sles.kernel, Some(Release::parse("4.12")?));
22//! assert!(sles.image().is_some());
23//!
24//! // A versioned target sets only the glibc floor; the kernel is not simulated.
25//! let target = Selection::resolve(&Request {
26//!     target: Some("x86_64-unknown-linux-gnu.2.17"),
27//!     ..Default::default()
28//! })?;
29//! assert_eq!(target.triple.as_deref(), Some("x86_64-unknown-linux-gnu"));
30//! assert_eq!(target.glibc, Some(Release::parse("2.17")?));
31//! assert_eq!(target.kernel, None);
32//! assert_eq!(target.describe(), "glibc 2.17, kernel not simulated (host)");
33//!
34//! // A preset cannot be combined with explicit floors.
35//! assert!(Selection::resolve(&Request {
36//!     distro: Some("centos-7"),
37//!     glibc: Some("2.28"),
38//!     ..Default::default()
39//! })
40//! .is_err());
41//! # Ok::<(), String>(())
42//! ```
43
44#![warn(missing_docs)]
45
46use std::cmp::Ordering;
47use std::fmt;
48
49const DISTRO_DATA: &str = include_str!("../data/distros.tsv");
50
51/// A `major.minor` release number, such as a kernel (4.12) or glibc (2.22)
52/// release. Comparison uses only these two components.
53#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
54pub struct Release {
55    /// The major number: 4 in kernel 4.12, 2 in glibc 2.22.
56    pub major: u32,
57    /// The minor number: 12 in kernel 4.12, 22 in glibc 2.22.
58    pub minor: u32,
59}
60
61impl Release {
62    /// Parses `4.12`, `4.12.14`, `v5.14`, or a distribution kernel release
63    /// such as `4.12.14-122.37-default`. Only the leading `major.minor` is
64    /// used.
65    ///
66    /// # Errors
67    ///
68    /// Returns a message when `s` does not start with two numbers separated by
69    /// a non-digit, such as `4` or `abc`.
70    pub fn parse(s: &str) -> Result<Self, String> {
71        let s = s.trim().trim_start_matches(['v', 'V']);
72        let mut parts = s.split(|c: char| !c.is_ascii_digit());
73        let major = parts.next().and_then(|p| p.parse().ok());
74        let minor = parts.next().and_then(|p| p.parse().ok());
75        match (major, minor) {
76            (Some(major), Some(minor)) => Ok(Release { major, minor }),
77            _ => Err(format!("not a release number: {s:?} (expected e.g. 4.12)")),
78        }
79    }
80}
81
82impl Ord for Release {
83    fn cmp(&self, other: &Self) -> Ordering {
84        (self.major, self.minor).cmp(&(other.major, other.minor))
85    }
86}
87
88impl PartialOrd for Release {
89    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
90        Some(self.cmp(other))
91    }
92}
93
94impl fmt::Display for Release {
95    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96        write!(f, "{}.{}", self.major, self.minor)
97    }
98}
99
100/// A mainline Linux kernel release.
101pub type KernelVersion = Release;
102/// A GNU C Library release.
103pub type GlibcVersion = Release;
104
105/// A distribution preset: a kernel floor, a glibc floor, and a test image.
106#[derive(Clone, Debug, PartialEq, Eq)]
107pub struct Distro {
108    /// The preset's id for `--distro`, such as `sles-12-sp5`.
109    pub id: &'static str,
110    /// The release's full name, such as `SUSE Linux Enterprise Server 12 SP5`.
111    pub name: &'static str,
112    /// Mainline base of the release's GA kernel.
113    pub kernel: KernelVersion,
114    /// The glibc release the distribution ships.
115    pub glibc: GlibcVersion,
116    /// Container image providing the release's userland.
117    pub image: &'static str,
118    /// Syscalls the vendor kernel backports beyond its mainline base.
119    pub kernel_backports: Vec<&'static str>,
120    /// How the values were established, e.g. `image-checked`.
121    pub status: &'static str,
122}
123
124/// All distribution presets from `data/distros.tsv`.
125pub fn distros() -> Vec<Distro> {
126    DISTRO_DATA
127        .lines()
128        .filter(|l| !l.trim().is_empty() && !l.starts_with('#'))
129        .map(|line| {
130            let cols: Vec<&'static str> = line.split('\t').map(str::trim).collect();
131            let [id, name, kernel, glibc, image, backports, status] = cols[..] else {
132                panic!("distro data row needs 7 columns: {cols:?}");
133            };
134            Distro {
135                id,
136                name,
137                kernel: Release::parse(kernel).expect("distro kernel"),
138                glibc: Release::parse(glibc).expect("distro glibc"),
139                image,
140                kernel_backports: if backports == "-" {
141                    Vec::new()
142                } else {
143                    backports.split(',').collect()
144                },
145                status,
146            }
147        })
148        .collect()
149}
150
151/// The preset with this id.
152pub fn distro(id: &str) -> Option<Distro> {
153    distros().into_iter().find(|d| d.id == id)
154}
155
156/// What the user selected, before validation.
157#[derive(Clone, Debug, Default, PartialEq, Eq)]
158pub struct Request<'a> {
159    /// `--distro ID`
160    pub distro: Option<&'a str>,
161    /// `--glibc X.Y`
162    pub glibc: Option<&'a str>,
163    /// `--kernel X.Y`
164    pub kernel: Option<&'a str>,
165    /// `--target TRIPLE` or `--target TRIPLE.X.Y`
166    pub target: Option<&'a str>,
167}
168
169/// A validated selection.
170#[derive(Clone, Debug, PartialEq, Eq)]
171pub struct Selection {
172    /// The preset, when one was chosen.
173    pub distro: Option<Distro>,
174    /// Rust target triple without any glibc suffix, when one was given.
175    pub triple: Option<String>,
176    /// glibc floor; `None` means the host's glibc.
177    pub glibc: Option<GlibcVersion>,
178    /// Kernel floor; `None` means the kernel is not simulated (host kernel).
179    pub kernel: Option<KernelVersion>,
180}
181
182impl Selection {
183    /// Applies the selection rules:
184    ///
185    /// - `distro` excludes `glibc`, `kernel`, and a versioned `target`.
186    /// - A versioned `target` (`TRIPLE.X.Y`) sets the glibc floor; it must
187    ///   agree with `glibc` if both are given. It never implies a kernel.
188    /// - Anything not given stays `None`: host glibc, host kernel.
189    ///
190    /// # Errors
191    ///
192    /// Returns a message when a preset is combined with explicit floors, the
193    /// preset id is unknown, a release number does not parse, `glibc` disagrees
194    /// with the target's suffix, or a suffixed target is not a `-gnu` triple.
195    pub fn resolve(req: &Request<'_>) -> Result<Self, String> {
196        let (triple, suffix) = match req.target {
197            Some(t) => {
198                let (triple, suffix) = split_versioned_target(t)?;
199                (Some(triple), suffix)
200            }
201            None => (None, None),
202        };
203        if let Some(id) = req.distro {
204            if req.glibc.is_some() || req.kernel.is_some() || suffix.is_some() {
205                return Err(format!(
206                    "--distro {id} sets the glibc and kernel floors; \
207                     do not combine it with --glibc, --kernel, or a versioned --target"
208                ));
209            }
210            let d = distro(id).ok_or_else(|| format!("unknown distro {id:?}"))?;
211            return Ok(Selection {
212                glibc: Some(d.glibc),
213                kernel: Some(d.kernel),
214                distro: Some(d),
215                triple,
216            });
217        }
218        let glibc = match (req.glibc.map(Release::parse).transpose()?, suffix) {
219            (Some(a), Some(b)) if a != b => {
220                return Err(format!("--glibc {a} conflicts with --target suffix .{b}"))
221            }
222            (a, b) => a.or(b),
223        };
224        Ok(Selection {
225            distro: None,
226            triple,
227            glibc,
228            kernel: req.kernel.map(Release::parse).transpose()?,
229        })
230    }
231
232    /// The container image for runtime tests, if the selection has one.
233    pub fn image(&self) -> Option<&'static str> {
234        self.distro.as_ref().map(|d| d.image)
235    }
236
237    /// A one-line description for tool output, e.g.
238    /// `glibc 2.22, kernel 4.12 (SUSE Linux Enterprise Server 12 SP5)` or
239    /// `glibc host, kernel not simulated (host)`.
240    pub fn describe(&self) -> String {
241        let glibc = self.glibc.map_or("host".to_string(), |g| g.to_string());
242        let kernel = self
243            .kernel
244            .map_or("not simulated (host)".to_string(), |k| k.to_string());
245        match &self.distro {
246            Some(d) => format!("glibc {glibc}, kernel {kernel} ({})", d.name),
247            None => format!("glibc {glibc}, kernel {kernel}"),
248        }
249    }
250}
251
252/// Splits `x86_64-unknown-linux-gnu.2.17` into the Rust triple and the glibc
253/// floor. A triple without a numeric `.X.Y` suffix is returned unchanged.
254///
255/// # Errors
256///
257/// Returns a message when a numeric suffix follows a triple that does not end
258/// in `-gnu`, since only glibc targets take a glibc floor.
259pub fn split_versioned_target(target: &str) -> Result<(String, Option<GlibcVersion>), String> {
260    let mut parts = target.rsplitn(3, '.');
261    let (minor, major, triple) = (parts.next(), parts.next(), parts.next());
262    match (triple, major, minor) {
263        (Some(triple), Some(major), Some(minor))
264            if !major.is_empty()
265                && major.bytes().all(|b| b.is_ascii_digit())
266                && !minor.is_empty()
267                && minor.bytes().all(|b| b.is_ascii_digit()) =>
268        {
269            if !triple.ends_with("-gnu") {
270                return Err(format!(
271                    "a glibc suffix needs a -gnu target triple: {target}"
272                ));
273            }
274            Ok((
275                triple.to_string(),
276                Some(Release::parse(&format!("{major}.{minor}"))?),
277            ))
278        }
279        _ => Ok((target.to_string(), None)),
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    fn r(s: &str) -> Release {
288        Release::parse(s).unwrap()
289    }
290
291    #[test]
292    fn parses_release_strings() {
293        assert_eq!(r("4.12.14-122.37-default"), r("4.12"));
294        assert_eq!(r("v5.14"), r("5.14.0"));
295        assert!(r("2.17") < r("2.22"));
296        assert!(Release::parse("linux").is_err());
297    }
298
299    #[test]
300    fn presets_parse() {
301        let all = distros();
302        assert_eq!(all.len(), 28);
303        let sles = distro("sles-12-sp5").unwrap();
304        assert_eq!((sles.glibc, sles.kernel), (r("2.22"), r("4.12")));
305        assert_eq!(sles.image, "registry.suse.com/suse/sles12sp5:latest");
306    }
307
308    #[test]
309    fn distro_sets_both_floors() {
310        let s = Selection::resolve(&Request {
311            distro: Some("sles-12-sp5"),
312            ..Default::default()
313        })
314        .unwrap();
315        assert_eq!((s.glibc, s.kernel), (Some(r("2.22")), Some(r("4.12"))));
316        assert_eq!(s.image(), Some("registry.suse.com/suse/sles12sp5:latest"));
317        assert_eq!(
318            s.describe(),
319            "glibc 2.22, kernel 4.12 (SUSE Linux Enterprise Server 12 SP5)"
320        );
321    }
322
323    #[test]
324    fn distro_excludes_explicit_floors() {
325        for req in [
326            Request {
327                distro: Some("sles-12-sp5"),
328                glibc: Some("2.28"),
329                ..Default::default()
330            },
331            Request {
332                distro: Some("sles-12-sp5"),
333                kernel: Some("5.14"),
334                ..Default::default()
335            },
336            Request {
337                distro: Some("sles-12-sp5"),
338                target: Some("x86_64-unknown-linux-gnu.2.17"),
339                ..Default::default()
340            },
341        ] {
342            assert!(Selection::resolve(&req).is_err(), "{req:?}");
343        }
344        assert!(Selection::resolve(&Request {
345            distro: Some("nope"),
346            ..Default::default()
347        })
348        .is_err());
349    }
350
351    #[test]
352    fn missing_kernel_means_host_kernel() {
353        let s = Selection::resolve(&Request {
354            target: Some("x86_64-unknown-linux-gnu.2.17"),
355            ..Default::default()
356        })
357        .unwrap();
358        assert_eq!(s.triple.as_deref(), Some("x86_64-unknown-linux-gnu"));
359        assert_eq!(
360            (s.glibc, s.kernel, s.image()),
361            (Some(r("2.17")), None, None)
362        );
363        assert_eq!(s.describe(), "glibc 2.17, kernel not simulated (host)");
364    }
365
366    #[test]
367    fn explicit_floors_combine() {
368        let s = Selection::resolve(&Request {
369            glibc: Some("2.22"),
370            kernel: Some("4.12"),
371            ..Default::default()
372        })
373        .unwrap();
374        assert_eq!((s.glibc, s.kernel), (Some(r("2.22")), Some(r("4.12"))));
375        let k = Selection::resolve(&Request {
376            kernel: Some("4.12"),
377            ..Default::default()
378        })
379        .unwrap();
380        assert_eq!((k.glibc, k.kernel), (None, Some(r("4.12"))));
381        assert_eq!(k.describe(), "glibc host, kernel 4.12");
382    }
383
384    #[test]
385    fn versioned_target_must_agree_with_glibc() {
386        let agree = Request {
387            glibc: Some("2.17"),
388            target: Some("x86_64-unknown-linux-gnu.2.17"),
389            ..Default::default()
390        };
391        assert_eq!(Selection::resolve(&agree).unwrap().glibc, Some(r("2.17")));
392        let conflict = Request {
393            glibc: Some("2.22"),
394            ..agree
395        };
396        assert!(Selection::resolve(&conflict).is_err());
397    }
398
399    #[test]
400    fn splits_versioned_targets() {
401        assert_eq!(
402            split_versioned_target("x86_64-unknown-linux-gnu.2.17").unwrap(),
403            ("x86_64-unknown-linux-gnu".to_string(), Some(r("2.17")))
404        );
405        assert_eq!(
406            split_versioned_target("x86_64-unknown-linux-gnu").unwrap(),
407            ("x86_64-unknown-linux-gnu".to_string(), None)
408        );
409        assert_eq!(
410            split_versioned_target("thumbv7em-none-eabihf").unwrap().1,
411            None
412        );
413        assert!(split_versioned_target("x86_64-unknown-linux-musl.1.2").is_err());
414    }
415}