Skip to main content

gix_config/file/includes/
mod.rs

1use std::path::{Path, PathBuf};
2
3use bstr::{BStr, BString, ByteSlice, ByteVec};
4use gix_error::{ErrorExt, ExnResult, OptionExt, ResultExt, message, not_found, validation};
5use gix_features::threading::OwnShared;
6use gix_ref::Category;
7
8use crate::{
9    File, file,
10    file::{Metadata, SectionId, includes, init},
11};
12
13impl File {
14    /// Traverse all `include` and `includeIf` directives found in this instance and follow them, loading the
15    /// referenced files from their location and adding their content right past the value that included them.
16    ///
17    /// # Limitations
18    ///
19    /// - Note that this method is _not idempotent_ and calling it multiple times will resolve includes multiple
20    ///   times. It's recommended use is as part of a multi-step bootstrapping which needs fine-grained control,
21    ///   and unless that's given one should prefer one of the other ways of initialization that resolve includes
22    ///   at the right time.
23    ///
24    /// # Deviation
25    ///
26    /// - included values are added after the _section_ that included them, not directly after the value. This is
27    ///   a deviation from how git does it, as it technically adds new value right after the include path itself,
28    ///   technically 'splitting' the section. This can only make a difference if the `include` section also has values
29    ///   which later overwrite portions of the included file, which seems unusual as these would be related to `includes`.
30    ///   We can fix this by 'splitting' the include section if needed so the included sections are put into the right place.
31    /// - `hasconfig:remote.*.url` will not prevent itself to include files with `[remote "name"]\nurl = x` values, but it also
32    ///   won't match them, i.e. one cannot include something that will cause the condition to match or to always be true.
33    pub fn resolve_includes(&mut self, options: init::Options<'_>) -> ExnResult {
34        if options.includes.max_depth == 0 {
35            return Ok(());
36        }
37        let mut buf = Vec::new();
38        resolve(self, &mut buf, options)
39    }
40}
41
42pub(crate) fn resolve(config: &mut File, buf: &mut Vec<u8>, options: init::Options<'_>) -> ExnResult {
43    resolve_includes_recursive(None, config, 0, buf, options)
44}
45
46fn resolve_includes_recursive(
47    search_config: Option<&File>,
48    target_config: &mut File,
49    depth: u8,
50    buf: &mut Vec<u8>,
51    options: init::Options<'_>,
52) -> ExnResult {
53    if depth == options.includes.max_depth {
54        return if options.includes.err_on_max_depth_exceeded {
55            Err(validation(format!(
56                "The maximum allowed length {} of the file include chain built by following nested resolve_includes is exceeded",
57                options.includes.max_depth
58            ))
59            .raise_erased())
60        } else {
61            Ok(())
62        };
63    }
64
65    for id in target_config.section_order.clone().into_iter() {
66        let section = &target_config.sections[&id];
67        let header = &section.header;
68        let backing = &target_config.backing;
69        let header_name = header.name.as_bstr_in(backing);
70        let mut paths = None;
71        if header_name == "include" && header.subsection_name.is_none() {
72            paths = Some(gather_paths(section, id, backing));
73        } else if header_name == "includeIf"
74            && let Some(condition) = &header.subsection_name
75        {
76            let target_config_path = section.meta.path.as_deref();
77            if include_condition_match(
78                condition.value_in(backing),
79                target_config_path,
80                search_config.unwrap_or(target_config),
81                options.includes,
82            )? {
83                paths = Some(gather_paths(section, id, backing));
84            }
85        }
86        if let Some(paths) = paths {
87            insert_includes_recursively(paths, target_config, depth, options, buf)?;
88        }
89    }
90    Ok(())
91}
92
93fn insert_includes_recursively(
94    section_ids_and_include_paths: Vec<(SectionId, crate::Path)>,
95    target_config: &mut File,
96    depth: u8,
97    options: init::Options<'_>,
98    buf: &mut Vec<u8>,
99) -> ExnResult {
100    for (section_id, config_path) in section_ids_and_include_paths {
101        let meta = OwnShared::clone(&target_config.sections[&section_id].meta);
102        let target_config_path = meta.path.as_deref();
103        let config_path = match resolve_path(config_path, target_config_path, options.includes)? {
104            Some(p) => p,
105            None => continue,
106        };
107        if !config_path.is_file() {
108            continue;
109        }
110
111        buf.clear();
112        std::io::copy(
113            &mut std::fs::File::open(&config_path).or_raise_erased(|| {
114                message!(
115                    "Could not read included configuration file at '{}'",
116                    config_path.display()
117                )
118            })?,
119            buf,
120        )
121        .or_raise_erased(|| message("Failed to copy configuration file into buffer"))?;
122        let config_meta = Metadata {
123            path: Some(config_path),
124            trust: meta.trust,
125            level: meta.level + 1,
126            source: meta.source,
127        };
128        let no_follow_options = init::Options {
129            includes: includes::Options::no_follow(),
130            ..options
131        };
132
133        let mut include_config = File::from_bytes_owned(buf, config_meta, no_follow_options)
134            .or_raise_erased(|| message("Could not parse included configuration file"))?;
135        resolve_includes_recursive(Some(target_config), &mut include_config, depth + 1, buf, options)?;
136
137        target_config
138            .append_or_insert(include_config, Some(section_id))
139            .or_raise_erased(|| message("Could not append included configuration"))?;
140    }
141    Ok(())
142}
143
144fn gather_paths(section: &file::SectionData, id: SectionId, backing: &[u8]) -> Vec<(SectionId, crate::Path)> {
145    section
146        .body
147        .values_in(backing, "path")
148        .into_iter()
149        .map(|path| (id, crate::Path::from(path)))
150        .collect()
151}
152
153fn include_condition_match(
154    condition: &BStr,
155    target_config_path: Option<&Path>,
156    search_config: &File,
157    options: Options<'_>,
158) -> ExnResult<bool> {
159    let mut tokens = condition.splitn(2, |b| *b == b':');
160    let (prefix, condition) = match (tokens.next(), tokens.next()) {
161        (Some(a), Some(b)) => (a, b),
162        _ => return Ok(false),
163    };
164    let condition = condition.as_bstr();
165    match prefix {
166        b"gitdir" => gitdir_matches(
167            condition,
168            target_config_path,
169            options,
170            gix_glob::wildmatch::Mode::empty(),
171        ),
172        b"gitdir/i" => gitdir_matches(
173            condition,
174            target_config_path,
175            options,
176            gix_glob::wildmatch::Mode::IGNORE_CASE,
177        ),
178        b"onbranch" => Ok(onbranch_matches(condition, options.conditional).is_some()),
179        b"hasconfig" => {
180            let mut tokens = condition.splitn(2, |b| *b == b':');
181            let (key_glob, value_glob) = match (tokens.next(), tokens.next()) {
182                (Some(a), Some(b)) => (a, b),
183                _ => return Ok(false),
184            };
185            if key_glob.as_bstr() != "remote.*.url" {
186                return Ok(false);
187            }
188            let Some(sections) = search_config.sections_by_name("remote") else {
189                return Ok(false);
190            };
191            for remote in sections {
192                for url in remote.values("url") {
193                    let glob_matches = gix_glob::wildmatch(
194                        value_glob.as_bstr(),
195                        url.as_ref(),
196                        gix_glob::wildmatch::Mode::NO_MATCH_SLASH_LITERAL,
197                    );
198                    if glob_matches {
199                        return Ok(true);
200                    }
201                }
202            }
203            Ok(false)
204        }
205        _ => Ok(false),
206    }
207}
208
209fn onbranch_matches(
210    condition: &BStr,
211    conditional::Context { branch_name, .. }: conditional::Context<'_>,
212) -> Option<()> {
213    let branch_name = branch_name?;
214    let (_, branch_name) = branch_name
215        .category_and_short_name()
216        .filter(|(cat, _)| *cat == Category::LocalBranch)?;
217
218    let condition: BString = if condition.ends_with(b"/") {
219        let mut condition: BString = condition.into();
220        condition.push_str("**");
221        condition
222    } else {
223        condition.into()
224    };
225
226    gix_glob::wildmatch(
227        condition.as_bstr(),
228        branch_name,
229        gix_glob::wildmatch::Mode::NO_MATCH_SLASH_LITERAL,
230    )
231    .then_some(())
232}
233
234fn gitdir_matches(
235    condition_path: &BStr,
236    target_config_path: Option<&Path>,
237    Options {
238        conditional: conditional::Context { git_dir, .. },
239        interpolate: context,
240        err_on_interpolation_failure,
241        err_on_missing_config_path,
242        ..
243    }: Options<'_>,
244    wildmatch_mode: gix_glob::wildmatch::Mode,
245) -> ExnResult<bool> {
246    if !err_on_interpolation_failure && git_dir.is_none() {
247        return Ok(false);
248    }
249    let git_dir = gix_path::to_unix_separators_on_windows(gix_path::into_bstr(git_dir.ok_or_raise_erased(|| {
250        not_found("The git directory must be provided to support `gitdir:` conditional includes")
251    })?));
252
253    let mut pattern_path = match check_interpolation_result(
254        err_on_interpolation_failure,
255        crate::Path::from(condition_path.to_owned()).interpolate(context),
256    )
257    .or_raise_erased(|| message("Could not interpolate conditional include path"))?
258    {
259        Some(path) => gix_path::into_bstr(path).into_owned(),
260        // Git keeps the original condition pattern when interpolation fails.
261        None => condition_path.to_owned(),
262    };
263    // NOTE: yes, only if we do path interpolation will the slashes be forced to unix separators on windows
264    if pattern_path != condition_path {
265        pattern_path = gix_path::to_unix_separators_on_windows(pattern_path).into_owned();
266    }
267
268    if let Some(relative_pattern_path) = pattern_path.strip_prefix(b"./") {
269        if !err_on_missing_config_path && target_config_path.is_none() {
270            return Ok(false);
271        }
272        let parent_dir = target_config_path
273            .ok_or_raise_erased(|| {
274                not_found(
275                    "Include paths from environment variables must not be relative as no config file path exists as root",
276                )
277            })?
278            .parent()
279            .expect("config path can never be /");
280        let mut joined_path = gix_path::to_unix_separators_on_windows(gix_path::into_bstr(parent_dir)).into_owned();
281        joined_path.push(b'/');
282        joined_path.extend_from_slice(relative_pattern_path);
283        pattern_path = joined_path;
284    }
285
286    // NOTE: this special handling of leading backslash is needed to do it like git does
287    if pattern_path.iter().next() != Some(&(std::path::MAIN_SEPARATOR as u8))
288        && !gix_path::from_bstr(pattern_path.clone()).is_absolute()
289    {
290        pattern_path.insert_str(0, "**/");
291    }
292    if pattern_path.ends_with(b"/") {
293        pattern_path.push_str("**");
294    }
295
296    let match_mode = gix_glob::wildmatch::Mode::NO_MATCH_SLASH_LITERAL | wildmatch_mode;
297    let is_match = gix_glob::wildmatch(pattern_path.as_bstr(), git_dir.as_bstr(), match_mode);
298    if is_match {
299        return Ok(true);
300    }
301
302    let expanded_git_dir = gix_path::to_unix_separators_on_windows(gix_path::into_bstr(
303        gix_path::realpath(gix_path::from_byte_slice(&git_dir))
304            .or_raise_erased(|| message("Could not resolve the git directory to its real path"))?,
305    ));
306    Ok(gix_glob::wildmatch(
307        pattern_path.as_bstr(),
308        expanded_git_dir.as_ref(),
309        match_mode,
310    ))
311}
312
313fn check_interpolation_result(disable: bool, res: ExnResult<impl Into<PathBuf>>) -> ExnResult<Option<PathBuf>> {
314    if disable {
315        return res.map(|path| Some(path.into()));
316    }
317    match res {
318        Ok(good) => Ok(Some(good.into())),
319        Err(err) if err.is_validation() => Err(err),
320        Err(_) => Ok(None),
321    }
322}
323
324fn resolve_path(
325    path: crate::Path,
326    target_config_path: Option<&Path>,
327    includes::Options {
328        interpolate: context,
329        err_on_interpolation_failure,
330        err_on_missing_config_path,
331        ..
332    }: includes::Options<'_>,
333) -> ExnResult<Option<PathBuf>> {
334    let path = match check_interpolation_result(err_on_interpolation_failure, path.interpolate(context))
335        .or_raise_erased(|| message("Could not interpolate include path"))?
336    {
337        Some(p) => p,
338        None => return Ok(None),
339    };
340    let path: PathBuf = if path.is_relative() {
341        if !err_on_missing_config_path && target_config_path.is_none() {
342            return Ok(None);
343        }
344        target_config_path
345            .ok_or_raise_erased(|| {
346                not_found(
347                    "Include paths from environment variables must not be relative as no config file path exists as root",
348                )
349            })?
350            .parent()
351            .expect("path is a config file which naturally lives in a directory")
352            .join(path)
353    } else {
354        path
355    };
356    Ok(Some(path))
357}
358
359mod types;
360pub use types::{Options, conditional};