1use serde_norway::{Mapping, Value};
26use std::collections::BTreeMap;
27
28use crate::manifest::{optional_string, parse_manifest_value, yaml_type_name};
29use crate::ManifestError;
30
31pub const PROGRAMS_KEY: &str = "programs";
33
34pub const ETC_CREDENTIAL_LOCATIONS: &[&str] = &[
38 "shadow",
39 "shadow-",
40 "gshadow",
41 "gshadow-",
42 "sudoers",
43 "sudoers.d",
44 "ssh",
45 "ssl/private",
46 "pki/tls/private",
47 "krb5.keytab",
48 "security",
49 "ipsec.secrets",
50 "ipsec.d",
51 "ppp",
52 "wpa_supplicant",
53 "NetworkManager/system-connections",
54];
55
56#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct ProgramNeeds {
59 pub program: String,
61 pub needs: Vec<DeclaredNeed>,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct DeclaredNeed {
67 pub form: NeedForm,
68 pub packages: BTreeMap<String, String>,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
78pub enum NeedForm {
79 Library(String),
81 File { file: String, beside: String },
83 ProgramFolder(String),
85 System(SystemLocation),
87 Etc(String),
89}
90
91impl std::fmt::Display for NeedForm {
92 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93 match self {
94 Self::Library(soname) => write!(f, "library {soname}"),
95 Self::File { file, beside } => write!(f, "file {file} beside {beside}"),
96 Self::ProgramFolder(path) => write!(f, "program_folder {path}"),
97 Self::System(location) => write!(f, "system {location}"),
98 Self::Etc(path) => write!(f, "etc {path}"),
99 }
100 }
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
105pub enum SystemLocation {
106 Fonts,
108 CaBundle,
110}
111
112impl SystemLocation {
113 pub const ALL: [Self; 2] = [Self::Fonts, Self::CaBundle];
115
116 #[must_use]
118 pub fn as_str(self) -> &'static str {
119 match self {
120 Self::Fonts => "fonts",
121 Self::CaBundle => "ca_bundle",
122 }
123 }
124}
125
126impl std::fmt::Display for SystemLocation {
127 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128 f.write_str(self.as_str())
129 }
130}
131
132const FORM_KEYS: [&str; 5] = ["library", "file", "program_folder", "system", "etc"];
134
135const ONE_FORM: &str = "a need holds exactly one form: library, file (with beside), \
137 program_folder, system or etc, and optionally packages";
138
139pub fn parse_program_needs(manifest_yaml: &str) -> Result<Vec<ProgramNeeds>, ManifestError> {
153 let value = parse_manifest_value(manifest_yaml)?;
154 let Some(root) = value.as_mapping() else {
155 return Ok(Vec::new());
156 };
157 let Some(block) = root.get(Value::String(PROGRAMS_KEY.to_string())) else {
158 return Ok(Vec::new());
159 };
160 let runtime = optional_string(root, "runtime").unwrap_or_default();
161 if matches!(runtime.as_str(), "driver" | "hook" | "skill") {
162 return Err(invalid(
163 PROGRAMS_KEY.to_string(),
164 format!(
165 "is only valid on an artifact whose runtime is tool; this one is runtime: \
166 {runtime}"
167 ),
168 ));
169 }
170 let block = mapping(block, PROGRAMS_KEY)?;
171 let mut programs = Vec::with_capacity(block.len());
172 for (key, entry) in block {
173 let Some(program) = key.as_str() else {
174 return Err(ManifestError::InvalidType {
175 field: format!("{PROGRAMS_KEY} key"),
176 expected: "string".to_string(),
177 got: yaml_type_name(key),
178 });
179 };
180 let field = format!("{PROGRAMS_KEY}.{program}");
181 if program.is_empty() || program.contains('/') || program == "." || program == ".." {
182 return Err(invalid(
183 format!("{PROGRAMS_KEY} key '{program}'"),
184 "is not a bare program name; a program is named as capabilities.shell.allow \
185 names it: no '/', not '.' or '..'"
186 .to_string(),
187 ));
188 }
189 programs.push(ProgramNeeds {
190 program: program.to_string(),
191 needs: parse_program(program, mapping(entry, &field)?, &field)?,
192 });
193 }
194 Ok(programs)
195}
196
197fn parse_program(
198 program: &str,
199 entry: &Mapping,
200 field: &str,
201) -> Result<Vec<DeclaredNeed>, ManifestError> {
202 if let Some(extra) = entry.keys().find(|key| {
203 !matches!(
204 key.as_str(),
205 Some("needs") | Some(crate::program_download::DOWNLOAD_KEY)
206 )
207 }) {
208 return Err(invalid(
209 format!("{field}.{}", key_text(extra)),
210 "is not a key of a program; a program holds needs, download or both".to_string(),
211 ));
212 }
213 let download = entry.get(Value::String(
214 crate::program_download::DOWNLOAD_KEY.to_string(),
215 ));
216 if let Some(download) = download {
217 crate::program_download::parse_download(
218 download,
219 &format!("{field}.{}", crate::program_download::DOWNLOAD_KEY),
220 )?;
221 }
222 let needs_field = format!("{field}.needs");
223 let Some(needs) = entry.get(Value::String("needs".to_string())) else {
224 if download.is_some() {
225 return Ok(Vec::new());
226 }
227 return Err(invalid(
228 field.to_string(),
229 "holds neither needs nor download; a program holds needs, download or both".to_string(),
230 ));
231 };
232 let needs = needs
233 .as_sequence()
234 .ok_or_else(|| ManifestError::InvalidType {
235 field: needs_field.clone(),
236 expected: "sequence".to_string(),
237 got: yaml_type_name(needs),
238 })?;
239 let mut parsed = Vec::with_capacity(needs.len());
240 for (index, need) in needs.iter().enumerate() {
241 let need_field = format!("{needs_field}[{index}]");
242 parsed.push(parse_need(mapping(need, &need_field)?, &need_field)?);
243 }
244 for (index, need) in parsed.iter().enumerate() {
245 if let NeedForm::File { beside, .. } = &need.form {
246 let declared = parsed
247 .iter()
248 .any(|other| matches!(&other.form, NeedForm::Library(soname) if soname == beside));
249 if !declared {
250 return Err(invalid(
251 format!("{needs_field}[{index}].beside"),
252 format!(
253 "is '{beside}', which names no library: entry of program '{program}'; \
254 beside names the library: value whose folder holds the file"
255 ),
256 ));
257 }
258 }
259 }
260 Ok(parsed)
261}
262
263fn parse_need(need: &Mapping, field: &str) -> Result<DeclaredNeed, ManifestError> {
264 if let Some(extra) = need.keys().find(|key| {
265 !key.as_str()
266 .is_some_and(|k| FORM_KEYS.contains(&k) || k == "beside" || k == "packages")
267 }) {
268 return Err(invalid(
269 format!("{field}.{}", key_text(extra)),
270 format!("is not a need key; {ONE_FORM}"),
271 ));
272 }
273 let forms: Vec<&str> = FORM_KEYS
274 .into_iter()
275 .filter(|key| need.contains_key(Value::String((*key).to_string())))
276 .collect();
277 let form_key = match forms.as_slice() {
278 [one] => *one,
279 [] => {
280 return Err(invalid(
281 field.to_string(),
282 format!("holds no form; {ONE_FORM}"),
283 ))
284 }
285 several => {
286 return Err(invalid(
287 field.to_string(),
288 format!("holds {}; {ONE_FORM}", several.join(" and ")),
289 ))
290 }
291 };
292 let text = |key: &str| -> Result<String, ManifestError> {
293 let value = need.get(Value::String(key.to_string())).ok_or_else(|| {
294 ManifestError::MissingField {
295 field: format!("{field}.{key}"),
296 }
297 })?;
298 value
299 .as_str()
300 .map(str::to_string)
301 .ok_or_else(|| ManifestError::InvalidType {
302 field: format!("{field}.{key}"),
303 expected: "string".to_string(),
304 got: yaml_type_name(value),
305 })
306 };
307 if form_key != "file" && need.contains_key(Value::String("beside".to_string())) {
308 return Err(invalid(
309 format!("{field}.beside"),
310 format!("goes only with file; this need's form is {form_key}"),
311 ));
312 }
313 let refuse = |key: &str, value: &str, reason: String| {
314 invalid(format!("{field}.{key}"), format!("is '{value}', {reason}"))
315 };
316 let form = match form_key {
317 "library" => {
318 let soname = text("library")?;
319 if let Some(reason) = host_path_reason(&soname) {
320 return Err(refuse("library", &soname, reason.to_string()));
321 }
322 if soname.contains('/') {
323 return Err(refuse(
324 "library",
325 &soname,
326 "a path; a library is named by its soname, a file name containing .so"
327 .to_string(),
328 ));
329 }
330 if !soname.contains(".so") {
331 return Err(refuse(
332 "library",
333 &soname,
334 "not a soname; a library is named by a file name containing .so".to_string(),
335 ));
336 }
337 NeedForm::Library(soname)
338 }
339 "file" => {
340 let file = text("file")?;
341 let beside = text("beside")?;
342 for (key, value) in [("file", &file), ("beside", &beside)] {
343 if let Some(reason) = host_path_reason(value) {
344 return Err(refuse(key, value, reason.to_string()));
345 }
346 if value.is_empty() || value.contains('/') || value == "." || value == ".." {
347 return Err(refuse(
348 key,
349 value,
350 "not a bare file name; it names a file in the folder its beside library \
351 resolved to"
352 .to_string(),
353 ));
354 }
355 }
356 NeedForm::File { file, beside }
357 }
358 "program_folder" => {
359 let path = text("program_folder")?;
360 if path != "." {
361 relative_path("program_folder", &path, &refuse)?;
362 }
363 NeedForm::ProgramFolder(path)
364 }
365 "system" => {
366 let name = text("system")?;
367 let location = SystemLocation::ALL
368 .into_iter()
369 .find(|location| location.as_str() == name)
370 .ok_or_else(|| {
371 refuse(
372 "system",
373 &name,
374 format!(
375 "which is not a system location; the known locations are: {}",
376 SystemLocation::ALL.map(SystemLocation::as_str).join(", ")
377 ),
378 )
379 })?;
380 NeedForm::System(location)
381 }
382 _ => {
383 let path = text("etc")?;
384 relative_path("etc", &path, &refuse)?;
385 if let Some(location) = credential_location_of(&path) {
386 return Err(refuse(
387 "etc",
388 &path,
389 format!(
390 "a credential location: /etc/{location} is never granted, nor a folder \
391 holding it"
392 ),
393 ));
394 }
395 NeedForm::Etc(path)
396 }
397 };
398 Ok(DeclaredNeed {
399 form,
400 packages: parse_packages(need, field)?,
401 })
402}
403
404fn host_path_reason(value: &str) -> Option<&'static str> {
407 if value.starts_with('~') {
408 Some("a home folder; a need names what the program needs, never where a host keeps it")
409 } else if value.contains('$') {
410 Some(
411 "an environment expansion; a need names what the program needs, never where a host \
412 keeps it",
413 )
414 } else if value == "/proc" || value.starts_with("/proc/") {
415 Some("under /proc, and /proc is the runtime's rule, never a need")
416 } else if value.starts_with('/') {
417 Some(
418 "an absolute host path; a need names what the program needs, never where a host \
419 keeps it",
420 )
421 } else {
422 None
423 }
424}
425
426pub(crate) fn relative_path(
428 key: &str,
429 path: &str,
430 refuse: &impl Fn(&str, &str, String) -> ManifestError,
431) -> Result<(), ManifestError> {
432 if let Some(reason) = host_path_reason(path) {
433 return Err(refuse(key, path, reason.to_string()));
434 }
435 if path.split('/').any(|component| component == "..") {
436 return Err(refuse(
437 key,
438 path,
439 "a path climbing with '..'; it must stay inside the folder it is relative to"
440 .to_string(),
441 ));
442 }
443 if path
444 .split('/')
445 .any(|component| component.is_empty() || component == ".")
446 {
447 return Err(refuse(
448 key,
449 path,
450 "not a relative path of plain components (no empty, '.' or '..' component)".to_string(),
451 ));
452 }
453 Ok(())
454}
455
456fn credential_location_of(path: &str) -> Option<&'static str> {
459 let components: Vec<&str> = path.split('/').collect();
460 ETC_CREDENTIAL_LOCATIONS.iter().copied().find(|location| {
461 let location: Vec<&str> = location.split('/').collect();
462 components.starts_with(&location) || location.starts_with(&components)
463 })
464}
465
466fn parse_packages(need: &Mapping, field: &str) -> Result<BTreeMap<String, String>, ManifestError> {
467 let Some(packages) = need.get(Value::String("packages".to_string())) else {
468 return Ok(BTreeMap::new());
469 };
470 let field = format!("{field}.packages");
471 let packages = mapping(packages, &field)?;
472 let mut parsed = BTreeMap::new();
473 for (id, package) in packages {
474 let id_text = id.as_str().unwrap_or_default();
475 let valid_id = !id_text.is_empty()
476 && id_text.bytes().all(|byte| {
477 byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"._-".contains(&byte)
478 });
479 if !valid_id {
480 return Err(invalid(
481 format!("{field}.{}", key_text(id)),
482 "is not an os-release ID; a packages key is a lowercase ID of [a-z0-9._-], as \
483 /etc/os-release spells ID and ID_LIKE"
484 .to_string(),
485 ));
486 }
487 let entry_field = format!("{field}.{id_text}");
488 let package = package.as_str().ok_or_else(|| ManifestError::InvalidType {
489 field: entry_field.clone(),
490 expected: "string".to_string(),
491 got: yaml_type_name(package),
492 })?;
493 if package.is_empty() || package.chars().any(char::is_whitespace) {
494 return Err(invalid(
495 entry_field,
496 format!("is '{package}', which is not a package name: non-empty, no whitespace"),
497 ));
498 }
499 parsed.insert(id_text.to_string(), package.to_string());
500 }
501 Ok(parsed)
502}
503
504pub(crate) fn mapping<'a>(value: &'a Value, field: &str) -> Result<&'a Mapping, ManifestError> {
505 value
506 .as_mapping()
507 .ok_or_else(|| ManifestError::InvalidType {
508 field: field.to_string(),
509 expected: "mapping".to_string(),
510 got: yaml_type_name(value),
511 })
512}
513
514pub(crate) fn key_text(key: &Value) -> String {
515 key.as_str()
516 .map_or_else(|| yaml_type_name(key), str::to_string)
517}
518
519pub(crate) fn invalid(field: String, message: String) -> ManifestError {
520 ManifestError::InvalidValue { field, message }
521}
522
523#[cfg(test)]
524mod tests {
525 use super::*;
526
527 fn tool(block: &str) -> String {
528 format!("name: needs-tool\nversion: 0.1.0\nruntime: tool\n{block}")
529 }
530
531 fn refusal(block: &str) -> String {
532 parse_program_needs(&tool(block))
533 .expect_err("the block is refused")
534 .to_string()
535 }
536
537 const EXAMPLE: &str = "programs:
539 chromium:
540 needs:
541 - library: libsoftokn3.so
542 packages:
543 debian: libnss3
544 fedora: nss-softokn
545 - file: libsoftokn3.chk
546 beside: libsoftokn3.so
547 - program_folder: .
548 - program_folder: lib/swiftshader
549 - system: fonts
550 - system: ca_bundle
551 - etc: fonts/fonts.conf
552";
553
554 #[test]
555 fn program_needs_absent_is_empty() {
556 assert_eq!(parse_program_needs(&tool("")).unwrap(), Vec::new());
557 assert_eq!(parse_program_needs("").unwrap(), Vec::new());
558 }
559
560 #[test]
561 fn program_needs_every_portable_form_parses() {
562 let parsed = parse_program_needs(&tool(EXAMPLE)).unwrap();
563 let none = BTreeMap::new;
564 let need = |form| DeclaredNeed {
565 form,
566 packages: none(),
567 };
568 assert_eq!(
569 parsed,
570 vec![ProgramNeeds {
571 program: "chromium".to_string(),
572 needs: vec![
573 DeclaredNeed {
574 form: NeedForm::Library("libsoftokn3.so".to_string()),
575 packages: BTreeMap::from([
576 ("debian".to_string(), "libnss3".to_string()),
577 ("fedora".to_string(), "nss-softokn".to_string()),
578 ]),
579 },
580 need(NeedForm::File {
581 file: "libsoftokn3.chk".to_string(),
582 beside: "libsoftokn3.so".to_string(),
583 }),
584 need(NeedForm::ProgramFolder(".".to_string())),
585 need(NeedForm::ProgramFolder("lib/swiftshader".to_string())),
586 need(NeedForm::System(SystemLocation::Fonts)),
587 need(NeedForm::System(SystemLocation::CaBundle)),
588 need(NeedForm::Etc("fonts/fonts.conf".to_string())),
589 ],
590 }]
591 );
592 let labels: Vec<String> = parsed[0].needs.iter().map(|n| n.form.to_string()).collect();
593 assert_eq!(
594 labels,
595 [
596 "library libsoftokn3.so",
597 "file libsoftokn3.chk beside libsoftokn3.so",
598 "program_folder .",
599 "program_folder lib/swiftshader",
600 "system fonts",
601 "system ca_bundle",
602 "etc fonts/fonts.conf",
603 ]
604 );
605 }
606
607 #[test]
608 fn program_needs_keep_program_order_and_allow_an_empty_list() {
609 let parsed = parse_program_needs(&tool(
610 "programs:\n ls:\n needs: []\n cat:\n needs:\n - library: libm.so.6\n",
611 ))
612 .unwrap();
613 assert_eq!(parsed.len(), 2);
614 assert_eq!(
615 (parsed[0].program.as_str(), parsed[0].needs.len()),
616 ("ls", 0)
617 );
618 assert_eq!(parsed[1].program, "cat");
619 }
620
621 #[test]
622 fn program_needs_a_host_path_is_refused_naming_program_index_value_and_reason() {
623 for (need, expected) in [
624 (
625 "program_folder: /home/someone/.fonts",
626 "field 'programs.cat.needs[0].program_folder' is '/home/someone/.fonts', an \
627 absolute host path",
628 ),
629 (
630 "library: /proc/self/maps",
631 "field 'programs.cat.needs[0].library' is '/proc/self/maps', under /proc, and \
632 /proc is the runtime's rule",
633 ),
634 (
635 "etc: shadow",
636 "field 'programs.cat.needs[0].etc' is 'shadow', a credential location",
637 ),
638 (
639 "etc: ssl",
640 "field 'programs.cat.needs[0].etc' is 'ssl', a credential location: \
641 /etc/ssl/private",
642 ),
643 (
644 "etc: ssh/sshd_config",
645 "field 'programs.cat.needs[0].etc' is 'ssh/sshd_config', a credential location",
646 ),
647 (
648 "etc: ../proc/cpuinfo",
649 "field 'programs.cat.needs[0].etc' is '../proc/cpuinfo', a path climbing with '..'",
650 ),
651 (
652 "library: ~/lib/libx.so",
653 "field 'programs.cat.needs[0].library' is '~/lib/libx.so', a home folder",
654 ),
655 (
656 "program_folder: $HOME/x",
657 "field 'programs.cat.needs[0].program_folder' is '$HOME/x', an environment \
658 expansion",
659 ),
660 (
661 "path: /usr/share/fonts",
662 "field 'programs.cat.needs[0].path' is not a need key",
663 ),
664 (
665 "system: everything",
666 "field 'programs.cat.needs[0].system' is 'everything', which is not a system \
667 location; the known locations are: fonts, ca_bundle",
668 ),
669 (
670 "library: lib/libx.so",
671 "field 'programs.cat.needs[0].library' is 'lib/libx.so', a path",
672 ),
673 (
674 "library: libx",
675 "field 'programs.cat.needs[0].library' is 'libx', not a soname",
676 ),
677 ("program_folder: share/../..", "a path climbing with '..'"),
678 (
679 "program_folder: ''",
680 "not a relative path of plain components",
681 ),
682 ("etc: .", "not a relative path of plain components"),
683 ("etc: /etc/fonts", "an absolute host path"),
684 (
685 "file: /usr/lib/x.chk\n beside: libm.so.6",
686 "an absolute host path",
687 ),
688 (
689 "file: x.chk",
690 "missing required field 'programs.cat.needs[0].beside'",
691 ),
692 ] {
693 let message = refusal(&format!("programs:\n cat:\n needs:\n - {need}\n"));
694 assert!(message.contains(expected), "{need}: {message}");
695 }
696 }
697
698 #[test]
699 fn program_needs_a_need_holds_exactly_one_form() {
700 let both = refusal(
701 "programs:\n cat:\n needs:\n - library: libm.so.6\n etc: fonts\n",
702 );
703 assert!(
704 both.contains("field 'programs.cat.needs[0]' holds library and etc; a need holds exactly one form"),
705 "{both}"
706 );
707 let none = refusal("programs:\n cat:\n needs:\n - packages: {debian: x}\n");
708 assert!(
709 none.contains("holds no form; a need holds exactly one form"),
710 "{none}"
711 );
712 let beside = refusal(
713 "programs:\n cat:\n needs:\n - library: libm.so.6\n - file: x.chk\n beside: libnone.so\n",
714 );
715 assert!(
716 beside.contains(
717 "field 'programs.cat.needs[1].beside' is 'libnone.so', which names no library: \
718 entry of program 'cat'"
719 ),
720 "{beside}"
721 );
722 let stray = refusal(
723 "programs:\n cat:\n needs:\n - etc: fonts\n beside: libm.so.6\n",
724 );
725 assert!(stray.contains("goes only with file"), "{stray}");
726 }
727
728 #[test]
729 fn program_needs_program_keys_and_packages_are_checked() {
730 for (block, expected) in [
731 ("programs:\n /usr/bin/cat:\n needs: []\n", "is not a bare program name"),
732 ("programs:\n '..':\n needs: []\n", "is not a bare program name"),
733 ("programs:\n cat:\n needs: []\n other: 1\n", "programs.cat.other' is not a key of a program"),
734 ("programs:\n cat: {}\n", "field 'programs.cat' holds neither needs nor download"),
735 ("programs: [cat]\n", "field 'programs' has invalid type (expected mapping"),
736 (
737 "programs:\n cat:\n needs:\n - library: libm.so.6\n packages: {Debian: x}\n",
738 "programs.cat.needs[0].packages.Debian' is not an os-release ID",
739 ),
740 (
741 "programs:\n cat:\n needs:\n - library: libm.so.6\n packages: {debian: 'lib m'}\n",
742 "is 'lib m', which is not a package name",
743 ),
744 (
745 "programs:\n cat:\n needs:\n - library: libm.so.6\n packages: {debian: ''}\n",
746 "which is not a package name",
747 ),
748 ] {
749 let message = refusal(block);
750 assert!(message.contains(expected), "{block}: {message}");
751 }
752 let accepted = parse_program_needs(&tool(
753 "programs:\n cat:\n needs:\n - library: libm.so.6\n packages: {opensuse-leap: libm6, rhel_8.1: glibc}\n",
754 ))
755 .unwrap();
756 assert_eq!(accepted[0].needs[0].packages.len(), 2);
757 }
758
759 #[test]
760 fn program_needs_the_block_is_refused_off_a_tool() {
761 for runtime in ["driver", "hook", "skill"] {
762 let message = parse_program_needs(&format!(
763 "name: x\nversion: 0.1.0\nruntime: {runtime}\nprograms:\n cat:\n needs: []\n"
764 ))
765 .unwrap_err()
766 .to_string();
767 assert!(
768 message.contains(&format!(
769 "field 'programs' is only valid on an artifact whose runtime is tool; this \
770 one is runtime: {runtime}"
771 )),
772 "{message}"
773 );
774 }
775 }
776
777 #[test]
778 fn program_needs_every_credential_location_is_refused_with_its_neighbours() {
779 for location in ETC_CREDENTIAL_LOCATIONS {
780 for path in [location.to_string(), format!("{location}/inner")] {
781 let message = refusal(&format!(
782 "programs:\n cat:\n needs:\n - etc: '{path}'\n"
783 ));
784 assert!(
785 message.contains("a credential location"),
786 "{path}: {message}"
787 );
788 }
789 }
790 assert!(parse_program_needs(&tool(
791 "programs:\n cat:\n needs:\n - etc: ssl/certs\n - etc: shadowsocks\n - etc: pki/tls/certs\n"
792 ))
793 .is_ok());
794 }
795}