dt_core/
registry.rs

1use std::{
2    collections::HashMap,
3    io::{Read, Seek},
4};
5
6use content_inspector::inspect;
7use handlebars::Handlebars;
8use serde::Serialize;
9
10use crate::{
11    config::DTConfig,
12    error::{Error as AppError, Result},
13};
14
15#[allow(unused_variables)]
16/// A registry should hold an environment of templates, and a cached storing
17/// the rendered contents.
18pub trait Register
19where
20    Self: Sized,
21{
22    /// Registers DT's [built-in helpers].
23    ///
24    /// [built-in helpers]: helpers
25    fn register_helpers(self) -> Result<Self> {
26        unimplemented!()
27    }
28    /// Load templates and render them into cached storage, items that are not
29    /// templated (see [`renderable`]) will not be registered into templates
30    /// but directly stored into the rendered cache.
31    ///
32    /// [`renderable`]: crate::config::Group::renderable
33    fn load(self, config: &DTConfig) -> Result<Self> {
34        unimplemented!()
35    }
36    /// Renders the template addressed by `name` and store the rendered
37    /// content into cache.
38    ///
39    /// Rendering only happens if this item is considered as a plain text
40    /// file.  If this item is considered as a binary file, it's original
41    /// content is returned.  The content type is inspected via the
42    /// [`content_inspector`] crate.  Although it can correctly determine if
43    /// an item is binary or text mostly of the time, it is just a heuristic
44    /// check and can fail in some cases, e.g. NUL byte in the first 1024
45    /// bytes of a UTF-8-encoded text file, etc..  See [the crate's home page]
46    /// for the full caveats.
47    ///
48    /// [`content_inspector`]: https://crates.io/crates/content_inspector
49    /// [the crate's home page]: https://github.com/sharkdp/content_inspector
50    fn update<S: Serialize>(&mut self, name: &str, ctx: &S) -> Result<()> {
51        unimplemented!()
52    }
53    /// Looks up the rendered content of an item with given name.
54    fn get(&self, name: &str) -> Result<Vec<u8>> {
55        unimplemented!()
56    }
57}
58
59/// Registry with a cache for rendered item contents.
60#[derive(Debug, Default)]
61pub struct Registry<'reg> {
62    /// The templates before rendering.
63    pub env: Handlebars<'reg>,
64    /// The rendered contents of items.
65    pub content: HashMap<String, Vec<u8>>,
66}
67
68impl Register for Registry<'_> {
69    fn register_helpers(self) -> Result<Self> {
70        let mut render_env = self.env;
71
72        render_env.register_helper("get_mine", Box::new(helpers::get_mine));
73        render_env.register_helper("if_user", Box::new(helpers::if_user));
74        render_env.register_helper("if_uid", Box::new(helpers::if_uid));
75        render_env.register_helper("if_host", Box::new(helpers::if_host));
76        render_env.register_helper("unless_user", Box::new(helpers::unless_user));
77        render_env.register_helper("unless_uid", Box::new(helpers::unless_uid));
78        render_env.register_helper("unless_host", Box::new(helpers::unless_host));
79        render_env.register_helper("if_os", Box::new(helpers::if_os));
80        render_env.register_helper("unless_os", Box::new(helpers::unless_os));
81
82        Ok(Self {
83            env: render_env,
84            ..self
85        })
86    }
87
88    fn load(self, config: &DTConfig) -> Result<Self> {
89        let mut registry = self;
90        for group in &config.local {
91            for s in &group.sources {
92                let name = s.to_string_lossy();
93
94                if group.is_renderable() {
95                    registry.update(&name, &config.context)?;
96                } else {
97                    log::trace!(
98                        "'{}' is from an unrenderable group '{}'",
99                        s.display(),
100                        group.name,
101                    );
102                }
103            }
104        }
105        Ok(registry)
106    }
107
108    fn update<S: Serialize>(&mut self, name: &str, ctx: &S) -> Result<()> {
109        let mut f = std::fs::File::open(name)?;
110        f.seek(std::io::SeekFrom::Start(0))?;
111        let mut indicator = vec![0; std::cmp::min(1024, f.metadata()?.len() as usize)];
112        f.read_exact(&mut indicator)?;
113        if inspect(&indicator).is_text() {
114            self.env
115                .register_template_string(name, std::fs::read_to_string(name)?)?;
116            self.content
117                .insert(name.to_owned(), self.env.render(name, ctx)?.into());
118        } else {
119            log::trace!("'{}' has binary contents, skipping rendering", name);
120            self.content.insert(name.to_owned(), std::fs::read(name)?);
121        }
122        Ok(())
123    }
124
125    fn get(&self, name: &str) -> Result<Vec<u8>> {
126        match self.content.get(name) {
127            Some(content) => Ok(content.to_owned()),
128            None => Err(AppError::TemplatingError(format!(
129                "The template specified by '{}' is not known",
130                name,
131            ))),
132        }
133    }
134}
135
136// ===========================================================================
137
138/// Additional built-in helpers
139pub mod helpers {
140    #[cfg(not(test))]
141    use {
142        gethostname::gethostname,
143        sys_info::linux_os_release,
144        users::{get_current_uid, get_current_username},
145    };
146
147    #[cfg(test)]
148    use crate::utils::testing::{
149        get_current_uid, get_current_username, gethostname, linux_os_release,
150    };
151
152    use handlebars::{
153        Context, Handlebars, Helper, HelperResult, JsonRender, Output, RenderContext, RenderError,
154        Renderable,
155    };
156
157    /// A templating helper that retrieves the value for current host from a
158    /// map, returns a default value when current host is not recorded in the
159    /// map.
160    ///
161    /// Usage:
162    ///
163    /// 1. `{{ get_mine }}`
164    ///
165    ///     Renders current machine's hostname.
166    /// 2. `{{ get_mine <map> <default-value> }}`
167    ///
168    ///     Renders `<map>.$CURRENT_HOSTNAME`, falls back to `<default-value>`.
169    pub fn get_mine(
170        h: &Helper,
171        _: &Handlebars,
172        _: &Context,
173        _rc: &mut RenderContext,
174        out: &mut dyn Output,
175    ) -> HelperResult {
176        let docmsg = format!(
177            r#"
178Inline helper `{0}`:
179    expected 0 or 2 arguments, 1 found
180
181    Usage:
182        1. {{{{ {0} }}}}
183           Renders current machine's hostname
184
185        2. {{{{ {0} <map> <default-value> }}}}
186           Gets value of <map>.$CURRENT_HOSTNAME, falls back to <default-value>"#,
187            h.name(),
188        );
189
190        let map = match h.param(0) {
191            Some(map) => map.value(),
192            None => {
193                out.write(&gethostname().to_string_lossy().to_string())?;
194                return Ok(());
195            }
196        };
197        let default_content = match h.param(1) {
198            Some(content) => content.value(),
199            None => {
200                return Err(RenderError::new(docmsg));
201            }
202        };
203
204        let content = match map.get(gethostname().to_string_lossy().to_string()) {
205            Some(content) => content.render(),
206            None => default_content.render(),
207        };
208
209        out.write(&content)?;
210
211        Ok(())
212    }
213
214    /// A templating helper that tests if current user's username matches a
215    /// set of given string(s).
216    ///
217    /// Usage:
218    ///
219    /// 1. `{{#if_user "foo,bar"}}..baz..{{/if_user}}`
220    ///
221    ///    Renders `..baz..` only if current user's username is either "foo"
222    ///    or "bar".
223    /// 2. `{{#if_user "foo"}}..baz..{{else}}..qux..{{/if_user}}`
224    ///
225    ///    Renders `..baz..` only if current user's username is "foo", renders
226    ///    `..qux..` only if current user's username is NOT "foo".
227    ///
228    /// 3. `{{#if_user some.array}}..foo..{{/if_user}}`
229    ///
230    ///    Renders `..foo..` only if current user's username is exactly one of
231    ///    the values from the templating variable `some.array` (defined in
232    ///    the config file's [`[context]`] section).
233    ///
234    /// [`[context]`]: dt_core::config::ContextConfig
235    pub fn if_user<'reg, 'rc>(
236        h: &Helper<'reg, 'rc>,
237        r: &'reg Handlebars<'reg>,
238        ctx: &'rc Context,
239        rc: &mut RenderContext<'reg, 'rc>,
240        out: &mut dyn Output,
241    ) -> HelperResult {
242        let docmsg = format!(
243            r#"
244Block helper `#{0}`:
245    expected exactly 1 argument, {1} found
246
247    Usage:
248        1. {{{{#{0} "foo,bar"}}}}..baz..{{{{/{0}}}}}
249           Renders `..baz..` only if current user's username is either "foo"
250           or "bar"
251
252        2. {{{{#{0} "foo"}}}}..baz..{{{{else}}}}..qux..{{{{/{0}}}}}
253           Renders `..baz..` only if current user's username is "foo", renders
254           `..qux..` only if current user's username is NOT "foo"
255
256        3. {{{{#{0} some.array}}}}..foo..{{{{/{0}}}}}
257           Renders `..foo..` only if current user's username is exactly one of
258           the values from the templating variable `some.array` (defined in
259           the config file's `[context]` section)"#,
260            h.name(),
261            h.params().len(),
262        );
263
264        if h.params().len() > 1 {
265            return Err(RenderError::new(docmsg));
266        }
267
268        let allowed_usernames: Vec<String> = match h.param(0) {
269            Some(v) => {
270                if v.value().is_array() {
271                    v.value()
272                        .as_array()
273                        .unwrap()
274                        .iter()
275                        .map(|elem| elem.render())
276                        .collect()
277                } else {
278                    v.value()
279                        .render()
280                        .split(',')
281                        .map(|u| u.trim().to_owned())
282                        .collect()
283                }
284            }
285            None => {
286                return Err(RenderError::new(docmsg));
287            }
288        };
289
290        let current_username = get_current_username()
291            .unwrap()
292            .to_string_lossy()
293            .to_string();
294        if !allowed_usernames.is_empty() {
295            if allowed_usernames.contains(&current_username) {
296                log::debug!(
297                    "Current username '{}' matches allowed usernames '{:?}'",
298                    current_username,
299                    allowed_usernames,
300                );
301                h.template().map(|t| t.render(r, ctx, rc, out));
302            } else {
303                log::debug!(
304                    "Current username '{}' does not match allowed usernames {:?}",
305                    current_username,
306                    allowed_usernames,
307                );
308                h.inverse().map(|t| t.render(r, ctx, rc, out));
309            }
310        } else {
311            return Err(RenderError::new(format!(
312                "no username(s) supplied for matching in helper {}",
313                h.name(),
314            )));
315        }
316        Ok(())
317    }
318
319    /// A templating helper that tests if current user's username does not
320    /// match a set of given string(s).  It is the negated version of
321    /// [`if_user`].
322    ///
323    /// Usage:
324    ///
325    /// 1. `{{#unless_user "foo,bar"}}..baz..{{/unless_user}}`
326    ///
327    ///    Renders `..baz..` only if current user's username is neither "foo"
328    ///    nor "bar".
329    /// 2. `{{#unless_user "foo"}}..baz..{{else}}..qux..{{/unless_user}}`
330    ///
331    ///    Renders `..baz..` only if current user's username is NOT "foo",
332    ///    renders `..qux..` only if current user's username is "foo".
333    ///
334    /// 3. `{{#unless_user some.array}}..foo..{{/unless_user}}`
335    ///
336    ///    Renders `..foo..` only if current user's username is none of the
337    ///    values from the templating variable `some.array` (defined in the
338    ///    config file's [`[context]`] section).
339    ///
340    /// [`if_user`]: if_user
341    /// [`[context]`]: dt_core::config::ContextConfig
342    pub fn unless_user<'reg, 'rc>(
343        h: &Helper<'reg, 'rc>,
344        r: &'reg Handlebars<'reg>,
345        ctx: &'rc Context,
346        rc: &mut RenderContext<'reg, 'rc>,
347        out: &mut dyn Output,
348    ) -> HelperResult {
349        let docmsg = format!(
350            r#"
351Block helper `#{0}`:
352    expected exactly 1 argument, {1} found
353
354    Usage:
355        1. {{{{#{0} "foo,bar"}}}}..baz..{{{{/{0}}}}}
356           Renders `..baz..` only if current user's username is neither "foo"
357           nor "bar"
358
359        2. {{{{#{0} "foo"}}}}..baz..{{{{else}}}}..qux..{{{{/{0}}}}}
360           Renders `..baz..` only if current user's username is NOT "foo",
361           renders `..qux..` only if current user's username is "foo"
362
363        3. {{{{#{0} some.array}}}}..foo..{{{{/{0}}}}}
364           Renders `..foo..` only if current user's username is none of the
365           values from the templating variable `some.array` (defined in the
366           config file's `[context]` section)"#,
367            h.name(),
368            h.params().len(),
369        );
370
371        if h.params().len() > 1 {
372            return Err(RenderError::new(docmsg));
373        }
374
375        let disallowed_usernames: Vec<String> = match h.param(0) {
376            Some(v) => {
377                if v.value().is_array() {
378                    v.value()
379                        .as_array()
380                        .unwrap()
381                        .iter()
382                        .map(|elem| elem.render())
383                        .collect()
384                } else {
385                    v.value()
386                        .render()
387                        .split(',')
388                        .map(|u| u.trim().to_owned())
389                        .collect()
390                }
391            }
392            None => {
393                return Err(RenderError::new(docmsg));
394            }
395        };
396
397        let current_username: String = get_current_username()
398            .unwrap()
399            .to_string_lossy()
400            .to_string();
401        if !disallowed_usernames.is_empty() {
402            if disallowed_usernames.contains(&current_username) {
403                log::debug!(
404                    "Current username '{}' matches disallowed usernames '{:?}'",
405                    current_username,
406                    disallowed_usernames,
407                );
408                h.inverse().map(|t| t.render(r, ctx, rc, out));
409            } else {
410                log::debug!(
411                    "Current username '{}' does not match disallowed usernames {:?}",
412                    current_username,
413                    disallowed_usernames,
414                );
415                h.template().map(|t| t.render(r, ctx, rc, out));
416            }
417        } else {
418            return Err(RenderError::new(format!(
419                "no username(s) supplied for matching in helper {}",
420                h.name(),
421            )));
422        }
423        Ok(())
424    }
425
426    /// A templating helper that tests if current user's effective uid matches
427    /// a set of given integer(s).
428    ///
429    /// Usage:
430    ///
431    /// 1. `{{#if_uid "1000,1001"}}..foo..{{/if_uid}}`
432    ///
433    ///    Renders `..foo..` only if current user's effective uid is either
434    ///    `1000` or `1001`.
435    /// 2. `{{#if_uid 0}}..foo..{{else}}..bar..{{/if_uid}}`
436    ///
437    ///    Renders `..foo..` only if current user's effective uid is `0`,
438    ///    renders `..bar..` only if current user's effective uid is not `0`.
439    /// 3. `{{#if_uid some.array}}..foo..{{/if_uid}}`
440    ///
441    ///    Renders `..foo..` only if current user's effective uid is exactly
442    ///    one of the values from the templating variable `some.array`
443    ///    (defined in the config file's [`[context]`] section).
444    ///
445    /// [`[context]`]: dt_core::config::ContextConfig
446    pub fn if_uid<'reg, 'rc>(
447        h: &Helper<'reg, 'rc>,
448        r: &'reg Handlebars<'reg>,
449        ctx: &'rc Context,
450        rc: &mut RenderContext<'reg, 'rc>,
451        out: &mut dyn Output,
452    ) -> HelperResult {
453        let docmsg = format!(
454            r#"
455Block helper `#{0}`:
456    expected exactly 1 argument, {1} found
457
458    Usage:
459        1. {{{{#{0} "1000,1001"}}}}..foo..{{{{/{0}}}}}
460           Renders `..foo..` only if current user's effective uid is either
461           `1000` or `1001`"
462
463        2. {{{{#{0} 0}}}}..foo..{{{{else}}}}..bar..{{{{/{0}}}}}
464           Renders `..foo..` only if current user's effective uid is `0`,
465           renders `..bar..` if current user's effective uid is not `0`
466
467        3. {{{{#{0} some.array}}}}..foo..{{{{/{0}}}}}
468           Renders `..foo..` only if current user's effective uid is exactly
469           one of the values from the templating variable `some.array`
470           (defined in the config file's `[context]` section)"#,
471            h.name(),
472            h.params().len(),
473        );
474
475        if h.params().len() > 1 {
476            return Err(RenderError::new(docmsg));
477        }
478
479        let allowed_uids: Vec<u32> = match h.param(0) {
480            Some(v) => {
481                if v.value().is_array() {
482                    v.value()
483                        .as_array()
484                        .unwrap()
485                        .iter()
486                        .map(|elem| elem.render().parse())
487                        .collect::<Result<Vec<_>, _>>()?
488                } else {
489                    v.value()
490                        .render()
491                        .split(',')
492                        .map(|uid| uid.parse())
493                        .collect::<Result<Vec<_>, _>>()?
494                }
495            }
496            None => {
497                return Err(RenderError::new(docmsg));
498            }
499        };
500
501        let current_uid = get_current_uid();
502        if !allowed_uids.is_empty() {
503            if allowed_uids.contains(&current_uid) {
504                log::debug!(
505                    "Current uid '{}' matches allowed uids '{:?}'",
506                    current_uid,
507                    allowed_uids,
508                );
509                h.template().map(|t| t.render(r, ctx, rc, out));
510            } else {
511                log::debug!(
512                    "Current uid '{}' does not match allowed uids {:?}",
513                    current_uid,
514                    allowed_uids,
515                );
516                h.inverse().map(|t| t.render(r, ctx, rc, out));
517            }
518        } else {
519            return Err(RenderError::new(format!(
520                "no uid(s) supplied for matching in helper {}",
521                h.name(),
522            )));
523        }
524        Ok(())
525    }
526
527    /// A templating helper that tests if current user's effective uid matches
528    /// a set of given integer(s).  It is the negated version of [`if_uid`].
529    ///
530    /// Usage:
531    ///
532    /// 1. `{{#unless_uid "1000,1001"}}..foo..{{/unless_uid}}`
533    ///
534    ///    Renders `..foo..` only if current user's effective uid is neither
535    ///    `1000` nor `1001`.
536    /// 2. `{{#unless_uid 0}}..foo..{{else}}..bar..{{/unless_uid}}`
537    ///
538    ///    Renders `..foo..` only if current user's effective uid is NOT `0`,
539    ///    renders `..bar..` only if current user's effective uid is `0`.
540    /// 3. `{{#unless_uid some.array}}..foo..{{/unless_uid}}`
541    ///
542    ///    Renders `..foo..` only if current user's effective uid is none of
543    ///    the values from the templating variable `some.array` (defined in
544    ///    the config file's [`[context]`] section).
545    ///
546    /// [`if_uid`]: if_uid
547    /// [`[context]`]: dt_core::config::ContextConfig
548    pub fn unless_uid<'reg, 'rc>(
549        h: &Helper<'reg, 'rc>,
550        r: &'reg Handlebars<'reg>,
551        ctx: &'rc Context,
552        rc: &mut RenderContext<'reg, 'rc>,
553        out: &mut dyn Output,
554    ) -> HelperResult {
555        let docmsg = format!(
556            r#"
557Block helper `#{0}`:
558    expected exactly 1 argument, {1} found
559
560    Usage:
561        1. {{{{#{0} "1000,1001"}}}}..foo..{{{{/{0}}}}}
562           Renders `..foo..` only if current user's effective uid is neither
563           `1000` nor `1001`"
564
565        2. {{{{#{0} 0}}}}..foo..{{{{else}}}}..bar..{{{{/{0}}}}}
566           Renders `..foo..` only if current user's effective uid is NOT `0`,
567           renders `..bar..` if current user's effective uid is `0`
568
569        3. {{{{#{0} some.array}}}}..foo..{{{{/{0}}}}}`
570           Renders `..foo..` only if current user's effective uid is none of
571           the values from the templating variable `some.array` (defined in
572           the config file's `[context]` section)"#,
573            h.name(),
574            h.params().len(),
575        );
576
577        if h.params().len() > 1 {
578            return Err(RenderError::new(docmsg));
579        }
580
581        let disallowed_uids: Vec<u32> = match h.param(0) {
582            Some(v) => {
583                if v.value().is_array() {
584                    v.value()
585                        .as_array()
586                        .unwrap()
587                        .iter()
588                        .map(|elem| elem.render().parse())
589                        .collect::<Result<Vec<_>, _>>()?
590                } else {
591                    v.value()
592                        .render()
593                        .split(',')
594                        .map(|uid| uid.parse())
595                        .collect::<Result<Vec<_>, _>>()?
596                }
597            }
598            None => {
599                return Err(RenderError::new(docmsg));
600            }
601        };
602
603        let current_uid = get_current_uid();
604        if !disallowed_uids.is_empty() {
605            if disallowed_uids.contains(&current_uid) {
606                log::debug!(
607                    "Current uid '{}' matches disallowed uids '{:?}'",
608                    current_uid,
609                    disallowed_uids,
610                );
611                h.inverse().map(|t| t.render(r, ctx, rc, out));
612            } else {
613                log::debug!(
614                    "Current uid '{}' does not match disallowed uids '{:?}'",
615                    current_uid,
616                    disallowed_uids,
617                );
618                h.template().map(|t| t.render(r, ctx, rc, out));
619            }
620        } else {
621            return Err(RenderError::new(format!(
622                "no uid(s) supplied for matching in helper {}",
623                h.name(),
624            )));
625        }
626        Ok(())
627    }
628
629    /// A templating helper that tests if current machine's hostname matches a
630    /// set of given string(s).
631    ///
632    /// Usage:
633    ///
634    /// 1. `{{#if_host "foo,bar"}}..baz..{{/if_host}}`
635    ///
636    ///    Renders `..baz..` only if current machine's hostname is either
637    ///    "foo" or "bar".
638    /// 2. `{{#if_host "foo"}}..baz..{{else}}..qux..{{/if_host}}`
639    ///
640    ///    Renders `..baz..` only if current machine's hostname is "foo",
641    ///    renders `..qux..` only if current user's username is NOT "foo".
642    /// 3. `{{#if_host some.array}}..foo..{{/if_host}}`
643    ///
644    ///    Renders `..foo..` only if current machine's hostname is exactly one
645    ///    of the values from the templating variable `some.array` (defined in
646    ///    the config file's [`[context]`] section).
647    ///
648    /// [`[context]`]: dt_core::config::ContextConfig
649    pub fn if_host<'reg, 'rc>(
650        h: &Helper<'reg, 'rc>,
651        r: &'reg Handlebars<'reg>,
652        ctx: &'rc Context,
653        rc: &mut RenderContext<'reg, 'rc>,
654        out: &mut dyn Output,
655    ) -> HelperResult {
656        let docmsg = format!(
657            r#"
658Block helper `#{0}`:
659    expected exactly 1 argument, {1} found
660
661    Usage:
662        1. {{{{#{0} "foo,bar"}}}}..bar..{{{{/{0}}}}}
663           Renders `..bar..` only if current machine's hostname is either
664           "foo" or "bar"
665
666        2. {{{{#{0} "foo"}}}}..baz..{{{{else}}}}..qux..{{{{/{0}}}}}
667           Renders `..baz..` only if current machine's hostname is "foo",
668           renders `..qux..` only if current user's username is NOT "foo"
669
670        3. {{{{#{0} some.array}}}}..foo..{{{{/{0}}}}}
671           Renders `..foo..` only if current machine's hostname is exactly one
672           of the values from the templating variable `some.array` (defined in
673           the config file's `[context]` section)"#,
674            h.name(),
675            h.params().len(),
676        );
677
678        if h.params().len() > 1 {
679            return Err(RenderError::new(docmsg));
680        }
681
682        let allowed_hostnames: Vec<String> = match h.param(0) {
683            Some(v) => {
684                if v.value().is_array() {
685                    v.value()
686                        .as_array()
687                        .unwrap()
688                        .iter()
689                        .map(|elem| elem.render())
690                        .collect::<Vec<_>>()
691                } else {
692                    v.value()
693                        .render()
694                        .split(',')
695                        .map(|h| h.trim().to_owned())
696                        .collect()
697                }
698            }
699            None => {
700                return Err(RenderError::new(docmsg));
701            }
702        };
703
704        let current_hostname = gethostname().to_string_lossy().to_string();
705        if !allowed_hostnames.is_empty() {
706            if allowed_hostnames.contains(&current_hostname) {
707                log::debug!(
708                    "Current hostname '{}' matches allowed hostnames '{:?}'",
709                    current_hostname,
710                    allowed_hostnames,
711                );
712                h.template().map(|t| t.render(r, ctx, rc, out));
713            } else {
714                log::debug!(
715                    "Current hostname '{}' does not match allowed hostnames '{:?}'",
716                    current_hostname,
717                    allowed_hostnames,
718                );
719                h.inverse().map(|t| t.render(r, ctx, rc, out));
720            }
721        } else {
722            return Err(RenderError::new(format!(
723                "no hostname(s) supplied for matching in helper {}",
724                h.name(),
725            )));
726        }
727        Ok(())
728    }
729
730    /// A templating helper that tests if current machine's hostname matches a
731    /// set of given string(s).  It is the negated version of [`if_host`]
732    ///
733    /// Usage:
734    ///
735    /// 1. `{{#unless_host "foo,bar"}}..baz..{{/unless_host}}`
736    ///
737    ///    Renders `..baz..` only if current machine's hostname is neither
738    ///    "foo" nor "bar".
739    /// 2. `{{#unless_host "foo"}}..baz..{{else}}..qux..{{/unless_host}}`
740    ///
741    ///    Renders `..baz..` only if current machine's hostname is NOT "foo",
742    ///    renders `..qux..` only if current user's username is "foo".
743    /// 3. `{{#unless_host some.array}}..foo..{{/unless_host}}`
744    ///
745    ///    Renders `..foo..` only if current machine's hostname is none of the
746    ///    values from the templating variable `some.array` (defined in the
747    ///    config file's [`[context]`] section).
748    ///
749    /// [`if_host`]: if_host
750    /// [`[context]`]: dt_core::config::ContextConfig
751    pub fn unless_host<'reg, 'rc>(
752        h: &Helper<'reg, 'rc>,
753        r: &'reg Handlebars<'reg>,
754        ctx: &'rc Context,
755        rc: &mut RenderContext<'reg, 'rc>,
756        out: &mut dyn Output,
757    ) -> HelperResult {
758        let docmsg = format!(
759            r#"
760Block helper `#{0}`:
761    expected exactly 1 argument, {1} found
762
763    Usage:
764        1. {{{{#{0} "foo,bar"}}}}..bar..{{{{/{0}}}}}
765           Renders `..bar..` only if current machine's hostname is neither
766           "foo" nor "bar"
767
768        2. {{{{#{0} "foo"}}}}..baz..{{{{else}}}}..qux..{{{{/{0}}}}}
769           Renders `..baz..` only if current machine's hostname is NOT "foo",
770           renders `..qux..` only if current user's username is "foo"
771
772        3. {{{{#{0} some.array}}}}..foo..{{{{/{0}}}}}
773           Renders `..foo..` only if current machine's hostname is none of the
774           values from the templating variable `some.array` (defined in the
775           config file's `[context]` section)"#,
776            h.name(),
777            h.params().len(),
778        );
779
780        if h.params().len() != 1 {
781            return Err(RenderError::new(docmsg));
782        }
783
784        let disallowed_hostnames: Vec<String> = match h.param(0) {
785            Some(v) => {
786                if v.value().is_array() {
787                    v.value()
788                        .as_array()
789                        .unwrap()
790                        .iter()
791                        .map(|elem| elem.render())
792                        .collect::<Vec<_>>()
793                } else {
794                    v.value()
795                        .render()
796                        .split(',')
797                        .map(|h| h.trim().to_owned())
798                        .collect()
799                }
800            }
801            None => {
802                return Err(RenderError::new(docmsg));
803            }
804        };
805
806        let current_hostname = gethostname().to_string_lossy().to_string();
807        if !disallowed_hostnames.is_empty() {
808            if disallowed_hostnames.contains(&current_hostname) {
809                log::debug!(
810                    "Current hostname '{}' matches disallowed hostnames '{:?}'",
811                    current_hostname,
812                    disallowed_hostnames,
813                );
814                h.inverse().map(|t| t.render(r, ctx, rc, out));
815            } else {
816                log::debug!(
817                    "Current hostname '{}' does not match disallowed hostnames '{:?}'",
818                    current_hostname,
819                    disallowed_hostnames,
820                );
821                h.template().map(|t| t.render(r, ctx, rc, out));
822            }
823        } else {
824            return Err(RenderError::new(format!(
825                "no hostname(s) supplied for matching in helper {}",
826                h.name(),
827            )));
828        }
829        Ok(())
830    }
831
832    /// A templating helper that conditions on values parsed from target
833    /// machine's /etc/os-release file.  The querying keys are case agnostic.
834    ///
835    /// Usage:
836    ///
837    /// 1. `{{#if_os "PRETTY_NAME" "foo,bar"}}..baz..{{/if_os}}`
838    ///
839    ///    Renders `..baz..` only if current machine's PRETTY_NAME is either
840    ///    "foo" or "bar"
841    /// 2. `{{#if_os "id" "foo"}}..baz..{{else}}..qux..{{/if_os}}`
842    ///
843    ///    Renders `..baz..` only if current machine's ID is "foo", renders
844    ///    `..qux..` only if current user's ID is NOT "foo"
845    /// 3. `{{#if_os "build_id" some.array}}..foo..{{/if_os}}`
846    ///
847    ///    Renders `..foo..` only if current machine's BUILD_ID is exactly one
848    ///    of the values from the templating variable `some.array` (defined in
849    ///    the config file's [`[context]`] section)"#,
850    ///
851    /// [`[context]`]: dt_core::config::ContextConfig
852    pub fn if_os<'reg, 'rc>(
853        h: &Helper<'reg, 'rc>,
854        r: &'reg Handlebars<'reg>,
855        ctx: &'rc Context,
856        rc: &mut RenderContext<'reg, 'rc>,
857        out: &mut dyn Output,
858    ) -> HelperResult {
859        let docmsg = format!(
860            r#"
861Block helper `#{0}`:
862    expected exactly 2 arguments, {1} found
863
864    Usage:
865        1. {{{{#{0} "PRETTY_NAME" "foo,bar"}}}}..baz..{{{{/{0}}}}}
866           Renders `..baz..` only if current machine's PRETTY_NAME is either
867           "foo" or "bar"
868
869        2. {{{{#{0} "id" "foo"}}}}..baz..{{{{else}}}}..qux..{{{{/{0}}}}}
870           Renders `..baz..` only if current machine's ID is "foo", renders
871           `..qux..` only if current user's ID is NOT "foo"
872
873        3. {{{{#{0} "build_id" some.array}}}}..foo..{{{{/{0}}}}}
874           Renders `..foo..` only if current machine's BUILD_ID is exactly one
875           of the values from the templating variable `some.array` (defined in
876           the config file's `[context]` section)"#,
877            h.name(),
878            h.params().len(),
879        );
880
881        if h.params().len() != 2 {
882            return Err(RenderError::new(docmsg));
883        }
884
885        if let Some(key) = h.param(0) {
886            let os_rel_info = match linux_os_release() {
887                Ok(info) => info,
888                Err(msg) => return Err(RenderError::new(msg.to_string())),
889            };
890            let query: &str = &key.value().render().to_uppercase();
891            let value = match query {
892                // REF: https://docs.rs/sys-info/latest/sys_info/struct.LinuxOSReleaseInfo.html
893                "ID" => os_rel_info.id,
894                "ID_LIKE" => os_rel_info.id_like,
895                "NAME" => os_rel_info.name,
896                "PRETTY_NAME" => os_rel_info.pretty_name,
897                "VERSION" => os_rel_info.version,
898                "VERSION_ID" => os_rel_info.version_id,
899                "VERSION_CODENAME" => os_rel_info.version_codename,
900                "ANSI_COLOR" => os_rel_info.ansi_color,
901                "LOGO" => os_rel_info.logo,
902                "CPE_NAME" => os_rel_info.cpe_name,
903                "BUILD_ID" => os_rel_info.build_id,
904                "VARIANT" => os_rel_info.variant,
905                "VARIANT_ID" => os_rel_info.variant_id,
906                "HOME_URL" => os_rel_info.home_url,
907                "DOCUMENTATION_URL" => os_rel_info.documentation_url,
908                "SUPPORT_URL" => os_rel_info.support_url,
909                "BUG_REPORT_URL" => os_rel_info.bug_report_url,
910                "PRIVACY_POLICY_URL" => os_rel_info.privacy_policy_url,
911                _ => None,
912            };
913            if value == None {
914                log::warn!(
915                    "/etc/os-release does not seem to provide '{}', see man:os-release(5) for more information",
916                    query,
917                );
918                h.inverse().map(|t| t.render(r, ctx, rc, out));
919                return Ok(());
920            }
921            let value = value.unwrap();
922
923            let allowed_values: Vec<String> = match h.param(1) {
924                Some(v) => {
925                    if v.value().is_array() {
926                        v.value()
927                            .as_array()
928                            .unwrap()
929                            .iter()
930                            .map(|elem| elem.render())
931                            .collect::<Vec<_>>()
932                    } else {
933                        v.value()
934                            .render()
935                            .split(',')
936                            .map(|h| h.trim().to_owned())
937                            .collect()
938                    }
939                }
940                None => {
941                    return Err(RenderError::new(docmsg));
942                }
943            };
944
945            if !allowed_values.is_empty() {
946                if allowed_values.contains(&value) {
947                    log::debug!(
948                        "Query result '{}' matches allowed value '{:?}'",
949                        value,
950                        allowed_values,
951                    );
952                    h.template().map(|t| t.render(r, ctx, rc, out));
953                } else {
954                    log::debug!(
955                        "Query result '{}' does not match allowed value '{:?}'",
956                        value,
957                        allowed_values,
958                    );
959                    h.inverse().map(|t| t.render(r, ctx, rc, out));
960                }
961            } else {
962                return Err(RenderError::new(format!(
963                    "no value(s) supplied for matching in helper {}",
964                    h.name(),
965                )));
966            }
967        } else {
968            return Err(RenderError::new(docmsg));
969        }
970
971        Ok(())
972    }
973
974    /// A templating helper that conditions on values parsed from target
975    /// machine's /etc/os-release file.  It is the negated version of
976    /// [`if_os`].  The querying keys are case agnostic.
977    ///
978    /// Usage:
979    /// 1. `{{#unless_os "PRETTY_NAME" "foo,bar"}}..baz..{{/unless_os}}
980    ///
981    ///    Renders `..baz..` only if current machine's PRETTY_NAME is neither
982    ///    "foo" nor "bar"
983    /// 2. `{{#unless_os "id" "foo"}}..baz..{{else}}..qux..{{/unless_os}}`
984    ///
985    ///    Renders `..baz..` only if current machine's ID is NOT "foo",
986    ///    renders `..qux..` only if current user's ID is "foo"
987    /// 3. `{{#unless_os "build_id" some.array}}..foo..{{/unless_os}}`
988    ///
989    ///    Renders `..foo..` only if current machine's BUILD_ID is none of the
990    ///    values from the templating variable `some.array` (defined in the
991    ///    config file's [`[context]`] section)"#,
992    ///
993    /// [`if_os`]: if_os
994    /// [`[context]`]: dt_core::config::ContextConfig
995    pub fn unless_os<'reg, 'rc>(
996        h: &Helper<'reg, 'rc>,
997        r: &'reg Handlebars<'reg>,
998        ctx: &'rc Context,
999        rc: &mut RenderContext<'reg, 'rc>,
1000        out: &mut dyn Output,
1001    ) -> HelperResult {
1002        let docmsg = format!(
1003            r#"
1004Block helper `#{0}`:
1005    expected exactly 2 arguments, {1} found
1006
1007    Usage:
1008        1. {{{{#{0} "PRETTY_NAME" "foo,bar"}}}}..baz..{{{{/{0}}}}}
1009           Renders `..baz..` only if current machine's PRETTY_NAME is neither
1010           "foo" nor "bar"
1011
1012        2. {{{{#{0} "id" "foo"}}}}..baz..{{{{else}}}}..qux..{{{{/{0}}}}}
1013           Renders `..baz..` only if current machine's ID is NOT "foo",
1014           renders `..qux..` only if current user's ID is "foo"
1015
1016        3. {{{{#{0} "build_id" some.array}}}}..foo..{{{{/{0}}}}}
1017           Renders `..foo..` only if current machine's BUILD_ID is none of the
1018           values from the templating variable `some.array` (defined in the
1019           config file's `[context]` section)"#,
1020            h.name(),
1021            h.params().len(),
1022        );
1023
1024        if h.params().len() != 2 {
1025            return Err(RenderError::new(docmsg));
1026        }
1027
1028        if let Some(key) = h.param(0) {
1029            let os_rel_info = match linux_os_release() {
1030                Ok(info) => info,
1031                Err(msg) => return Err(RenderError::new(msg.to_string())),
1032            };
1033            let query: &str = &key.value().render().to_uppercase();
1034            let value = match query {
1035                // REF: https://docs.rs/sys-info/latest/sys_info/struct.LinuxOSReleaseInfo.html
1036                "ID" => os_rel_info.id,
1037                "ID_LIKE" => os_rel_info.id_like,
1038                "NAME" => os_rel_info.name,
1039                "PRETTY_NAME" => os_rel_info.pretty_name,
1040                "VERSION" => os_rel_info.version,
1041                "VERSION_ID" => os_rel_info.version_id,
1042                "VERSION_CODENAME" => os_rel_info.version_codename,
1043                "ANSI_COLOR" => os_rel_info.ansi_color,
1044                "LOGO" => os_rel_info.logo,
1045                "CPE_NAME" => os_rel_info.cpe_name,
1046                "BUILD_ID" => os_rel_info.build_id,
1047                "VARIANT" => os_rel_info.variant,
1048                "VARIANT_ID" => os_rel_info.variant_id,
1049                "HOME_URL" => os_rel_info.home_url,
1050                "DOCUMENTATION_URL" => os_rel_info.documentation_url,
1051                "SUPPORT_URL" => os_rel_info.support_url,
1052                "BUG_REPORT_URL" => os_rel_info.bug_report_url,
1053                "PRIVACY_POLICY_URL" => os_rel_info.privacy_policy_url,
1054                _ => None,
1055            };
1056            if value == None {
1057                log::warn!(
1058                    "/etc/os-release does not seem to provide '{}', see man:os-release(5) for more information",
1059                    query,
1060                );
1061                h.inverse().map(|t| t.render(r, ctx, rc, out));
1062                return Ok(());
1063            }
1064            let value = value.unwrap();
1065
1066            let disallowed_values: Vec<String> = match h.param(1) {
1067                Some(v) => {
1068                    if v.value().is_array() {
1069                        v.value()
1070                            .as_array()
1071                            .unwrap()
1072                            .iter()
1073                            .map(|elem| elem.render())
1074                            .collect::<Vec<_>>()
1075                    } else {
1076                        v.value()
1077                            .render()
1078                            .split(',')
1079                            .map(|h| h.trim().to_owned())
1080                            .collect()
1081                    }
1082                }
1083                None => {
1084                    return Err(RenderError::new(docmsg));
1085                }
1086            };
1087
1088            if !disallowed_values.is_empty() {
1089                if disallowed_values.contains(&value) {
1090                    log::debug!(
1091                        "Query result '{}' matches disallowed value '{:?}'",
1092                        value,
1093                        disallowed_values,
1094                    );
1095                    h.inverse().map(|t| t.render(r, ctx, rc, out));
1096                } else {
1097                    log::debug!(
1098                        "Query result '{}' does not match disallowed value '{:?}'",
1099                        value,
1100                        disallowed_values,
1101                    );
1102                    h.template().map(|t| t.render(r, ctx, rc, out));
1103                }
1104            } else {
1105                return Err(RenderError::new(format!(
1106                    "no value(s) supplied for matching in helper {}",
1107                    h.name(),
1108                )));
1109            }
1110        } else {
1111            return Err(RenderError::new(docmsg));
1112        }
1113
1114        Ok(())
1115    }
1116}
1117
1118// Author: Blurgy <gy@blurgy.xyz>
1119// Date:   Jan 29 2022, 14:42 [CST]