1use std::fmt::Display;
43use std::time::Duration;
44
45use crate::error::RegentError;
46use crate::hosts::managed_host::InternalApiCallOutcome;
47use crate::hosts::managed_host::{AssessCompliance, ReachCompliance, Timeout};
48use crate::hosts::properties::HostProperties;
49use crate::secrets::SecretProvidersPool;
50use crate::state::Check;
51use crate::state::attribute::HostHandler;
52use crate::state::attribute::Privilege;
53use crate::state::attribute::Remediation;
54use crate::state::attribute::RemediationsList;
55use crate::state::compliance::AttributeComplianceAssessment;
56use crate::state::expected_state::Parameter;
57use serde::{Deserialize, Serialize};
58
59#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61#[serde(rename_all = "PascalCase")]
62pub enum LineExpectedState {
63 #[serde(rename_all = "PascalCase")]
65 Present {
66 #[serde(default = "default_line_position")]
67 position: LinePosition,
68 firstmatch: Option<bool>,
70 create: Option<bool>,
72 },
73 #[serde(rename_all = "PascalCase")]
75 Absent {
76 #[serde(default)] file_must_exist_anyway: bool,
79 },
80}
81
82#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
83#[serde(rename_all = "PascalCase")]
84pub enum Line {
85 Raw(Parameter<String>),
87 Regexp(String),
89 #[serde(rename_all = "PascalCase")]
90 RegexpWithBackrefs {
91 regexp: String,
93 content_to_insert: String,
95 },
96 SearchString(String),
98}
99
100impl Display for Line {
101 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102 match self {
103 Line::Raw(_) => write!(f, "Line::Raw"),
104 Line::Regexp(_) => write!(f, "Line::Regexp"),
105 Line::RegexpWithBackrefs {
106 regexp,
107 content_to_insert,
108 } => write!(f, "Line::RegexpWithBackrefs"),
109 Line::SearchString(_) => write!(f, "Line::SearchString"),
110 }
111 }
112}
113
114#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
115#[serde(rename_all = "PascalCase")]
116pub enum LinePosition {
117 InsertAfter(String),
119 InsertBefore(String),
121}
122
123fn default_line_position() -> LinePosition {
124 LinePosition::InsertAfter("EOF".to_string())
125}
126
127#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
129#[serde(deny_unknown_fields)]
130#[serde(rename_all = "PascalCase")]
131pub struct LineInFileBlockExpectedState {
132 file_path: String,
134 state: LineExpectedState,
136 line: Line,
137}
138
139impl LineInFileBlockExpectedState {
140 pub fn absent(
148 file_path: &str,
149 line: Line,
150 file_must_exist_anyway: bool,
151 ) -> LineInFileBlockExpectedState {
152 LineInFileBlockExpectedState {
153 file_path: file_path.to_string(),
154 state: LineExpectedState::Absent {
155 file_must_exist_anyway,
156 },
157 line,
158 }
159 }
160
161 pub fn present_at_top(
168 file_path: &str,
169 line: Line,
170 create: Option<bool>,
171 ) -> LineInFileBlockExpectedState {
172 LineInFileBlockExpectedState {
173 file_path: file_path.to_string(),
174 state: LineExpectedState::Present {
175 position: LinePosition::InsertBefore("BOF".to_string()),
176 firstmatch: None,
177 create,
178 },
179 line,
180 }
181 }
182
183 pub fn present_at_bottom(
188 file_path: &str,
189 line: Line,
190 create: Option<bool>,
191 ) -> LineInFileBlockExpectedState {
192 LineInFileBlockExpectedState {
193 file_path: file_path.to_string(),
194 state: LineExpectedState::Present {
195 position: LinePosition::InsertAfter("EOF".to_string()),
196 firstmatch: None,
197 create,
198 },
199 line,
200 }
201 }
202
203 pub fn present_after(
210 file_path: &str,
211 line: Line,
212 insert_after: &str,
213 firstmatch: Option<bool>,
214 create: Option<bool>,
215 ) -> LineInFileBlockExpectedState {
216 LineInFileBlockExpectedState {
217 file_path: file_path.to_string(),
218 state: LineExpectedState::Present {
219 position: LinePosition::InsertAfter(insert_after.to_string()),
220 firstmatch,
221 create,
222 },
223 line,
224 }
225 }
226
227 pub fn present_before(
234 file_path: &str,
235 line: Line,
236 insert_before: &str,
237 firstmatch: Option<bool>,
238 create: Option<bool>,
239 ) -> LineInFileBlockExpectedState {
240 LineInFileBlockExpectedState {
241 file_path: file_path.to_string(),
242 state: LineExpectedState::Present {
243 position: LinePosition::InsertBefore(insert_before.to_string()),
244 firstmatch,
245 create,
246 },
247 line,
248 }
249 }
250
251 pub fn present_anywhere(
256 file_path: &str,
257 line: Line,
258 create: Option<bool>,
259 ) -> LineInFileBlockExpectedState {
260 LineInFileBlockExpectedState {
261 file_path: file_path.to_string(),
262 state: LineExpectedState::Present {
263 position: LinePosition::InsertAfter("EOF".to_string()), firstmatch: None,
265 create,
266 },
267 line,
268 }
269 }
270
271 pub fn raw_line(content: Parameter<String>) -> Line {
275 Line::Raw(content)
276 }
277
278 pub fn regexp_line(pattern: &str) -> Line {
280 Line::Regexp(pattern.to_string())
281 }
282
283 pub fn regexp_with_backrefs_line(regexp: &str, content_to_insert: &str) -> Line {
285 Line::RegexpWithBackrefs {
286 regexp: regexp.to_string(),
287 content_to_insert: content_to_insert.to_string(),
288 }
289 }
290
291 pub fn search_string_line(pattern: &str) -> Line {
293 Line::SearchString(pattern.to_string())
294 }
295}
296
297impl Check for LineInFileBlockExpectedState {
298 fn check(&self) -> Result<(), RegentError> {
299 if self.file_path.is_empty() {
300 return Err(RegentError::IncoherentExpectedState(
301 "FilePath cannot be empty.".to_string(),
302 ));
303 }
304 Ok(())
305 }
306
307 fn check_host_compatibility(
308 &self,
309 _host_properties: &HostProperties,
310 ) -> Result<(), RegentError> {
311 Ok(())
313 }
314}
315
316impl Timeout for LineInFileBlockExpectedState {
317 fn default_timeout(&self) -> Duration {
318 Duration::from_secs(1)
319 }
320}
321
322impl<Handler: HostHandler> AssessCompliance<Handler> for LineInFileBlockExpectedState {
323 async fn assess_compliance(
324 &self,
325 host_handler: &mut Handler,
326 host_properties: &Option<HostProperties>,
327 privilege: &Privilege,
328 optional_secret_provider: &Option<SecretProvidersPool>,
329 ) -> Result<AttributeComplianceAssessment, RegentError> {
330 if let Some(props) = host_properties {
332 self.check_host_compatibility(props)?;
333 }
334 if !host_handler
335 .is_this_command_available("sed", privilege)
336 .await
337 .unwrap()
338 {
339 return Err(RegentError::FailedDryRunEvaluation(
340 "sed is not available on this host".to_string(),
341 ));
342 }
343
344 let file_exists = host_handler
345 .run_command(&format!("test -f {}", self.file_path), &Privilege::None)
346 .await
347 .unwrap()
348 .return_code
349 == 0;
350
351 match &self.state {
352 LineExpectedState::Absent {
353 file_must_exist_anyway,
354 } => match (file_exists, file_must_exist_anyway) {
355 (false, true) => Err(RegentError::FailedDryRunEvaluation(format!(
356 "File {} is expected to exist but it does not",
357 self.file_path
358 ))),
359 (false, false) => Ok(AttributeComplianceAssessment::Compliant),
360 (true, _) => {
361 assess_absence(
362 self,
363 host_handler,
364 privilege,
365 &self.line,
366 optional_secret_provider,
367 )
368 .await
369 }
370 },
371 LineExpectedState::Present {
372 position,
373 firstmatch,
374 create,
375 } => {
376 let create = create.unwrap_or(false);
377 let firstmatch = firstmatch.unwrap_or(false);
378
379 match (file_exists, create) {
380 (true, _) => {
381 assess_presence(
382 self,
383 host_handler,
384 privilege,
385 &self.line,
386 firstmatch,
387 optional_secret_provider,
388 )
389 .await
390 }
391 (false, true) => {
392 match &self.line {
394 Line::Raw(parameter_line) => {
395 let line_content = parameter_line
396 .clone()
397 .inner_raw(optional_secret_provider)
398 .await?;
399 Ok(AttributeComplianceAssessment::NonCompliant(
400 RemediationsList::from(vec![Remediation::LineInFile(
401 LineInFileApiCall {
402 file_path: self.file_path.clone(),
403 line: self.line.clone(), api_call: LineInFileModuleInternalApiCall::CreateFile {
405 line_content: line_content,
406 },
407 privilege: privilege.clone(),
408 },
409 )])
410 .unwrap(),
411 ))
412 }
413 _ => Err(RegentError::FailedDryRunEvaluation(format!(
414 "{}: file not found and cannot be created automatically. Only Line::Raw is supported for automatic file creation",
415 self.file_path
416 ))),
417 }
418 }
419 (false, false) => Err(RegentError::FailedDryRunEvaluation(format!(
420 "{}: file not found (set Create: true to create it)",
421 self.file_path
422 ))),
423 }
424 }
425 }
426 }
427}
428
429async fn assess_absence<Handler: HostHandler>(
430 block: &LineInFileBlockExpectedState,
431 host_handler: &mut Handler,
432 privilege: &Privilege,
433 line: &Line, optional_secret_provider: &Option<SecretProvidersPool>,
435) -> Result<AttributeComplianceAssessment, RegentError> {
436 match line {
437 Line::Raw(parameter_line) => {
438 let content_to_search = parameter_line
439 .clone()
440 .inner_raw(optional_secret_provider)
441 .await?;
442 let matches = grep_exact_line(host_handler, &content_to_search, &block.file_path).await;
443 if matches.is_empty() {
444 return Ok(AttributeComplianceAssessment::Compliant);
445 }
446 Ok(AttributeComplianceAssessment::NonCompliant(
447 RemediationsList::from(vec![Remediation::LineInFile(LineInFileApiCall {
448 file_path: block.file_path.clone(),
449 line: line.clone(), api_call: LineInFileModuleInternalApiCall::DeleteLines {
451 line_numbers: matches,
452 },
453 privilege: privilege.clone(),
454 })])
455 .unwrap(),
456 ))
457 }
458 Line::Regexp(regexp) => {
459 let matches = grep_lines(host_handler, regexp, &block.file_path, false).await;
460 if matches.is_empty() {
461 return Ok(AttributeComplianceAssessment::Compliant);
462 }
463 Ok(AttributeComplianceAssessment::NonCompliant(
464 RemediationsList::from(vec![Remediation::LineInFile(LineInFileApiCall {
465 file_path: block.file_path.clone(),
466 line: line.clone(), api_call: LineInFileModuleInternalApiCall::DeleteByRegexp {
468 regexp: regexp.clone(),
469 },
470 privilege: privilege.clone(),
471 })])
472 .unwrap(),
473 ))
474 }
475 Line::RegexpWithBackrefs {
476 regexp,
477 content_to_insert,
478 } => {
479 let matches = grep_lines(host_handler, regexp, &block.file_path, false).await;
480 if matches.is_empty() {
481 return Ok(AttributeComplianceAssessment::Compliant);
482 }
483 Ok(AttributeComplianceAssessment::NonCompliant(
484 RemediationsList::from(vec![Remediation::LineInFile(LineInFileApiCall {
485 file_path: block.file_path.clone(),
486 line: line.clone(), api_call: LineInFileModuleInternalApiCall::DeleteByRegexp {
488 regexp: regexp.clone(),
489 },
490 privilege: privilege.clone(),
491 })])
492 .unwrap(),
493 ))
494 }
495 Line::SearchString(search_string) => {
496 let matches = grep_lines(host_handler, search_string, &block.file_path, false).await;
497 if matches.is_empty() {
498 return Ok(AttributeComplianceAssessment::Compliant);
499 }
500 Ok(AttributeComplianceAssessment::NonCompliant(
501 RemediationsList::from(vec![Remediation::LineInFile(LineInFileApiCall {
502 file_path: block.file_path.clone(),
503 line: line.clone(), api_call: LineInFileModuleInternalApiCall::DeleteLines {
505 line_numbers: matches,
506 },
507 privilege: privilege.clone(),
508 })])
509 .unwrap(),
510 ))
511 }
512 }
513}
514
515async fn assess_presence<Handler: HostHandler>(
516 block: &LineInFileBlockExpectedState,
517 host_handler: &mut Handler,
518 privilege: &Privilege,
519 line: &Line, firstmatch: bool,
521 optional_secret_provider: &Option<SecretProvidersPool>,
522) -> Result<AttributeComplianceAssessment, RegentError> {
523 match line {
524 Line::Raw(parameter_line) => {
525 let content_to_search = parameter_line
526 .clone()
527 .inner_raw(optional_secret_provider)
528 .await?;
529 let matches = grep_exact_line(host_handler, &content_to_search, &block.file_path).await;
530 if !matches.is_empty() {
531 return Ok(AttributeComplianceAssessment::Compliant);
532 }
533 }
535 Line::Regexp(regexp) => {
536 let matches = grep_lines(host_handler, regexp, &block.file_path, false).await;
537 if !matches.is_empty() {
538 return Ok(AttributeComplianceAssessment::Compliant);
539 }
540 }
542 Line::RegexpWithBackrefs {
543 regexp,
544 content_to_insert,
545 } => {
546 let matches = grep_lines(host_handler, regexp, &block.file_path, false).await;
547 if !matches.is_empty() {
548 let target = if firstmatch {
549 *matches.first().unwrap()
550 } else {
551 *matches.last().unwrap()
552 };
553 return Ok(AttributeComplianceAssessment::NonCompliant(
554 RemediationsList::from(vec![Remediation::LineInFile(LineInFileApiCall {
555 file_path: block.file_path.clone(),
556 line: line.clone(), api_call: LineInFileModuleInternalApiCall::ReplaceWithBackrefs {
558 line_content: content_to_insert.clone(),
559 line_number: target,
560 regexp: regexp.clone(),
561 },
562 privilege: privilege.clone(),
563 })])
564 .unwrap(),
565 ));
566 }
567 }
569 Line::SearchString(search_string) => {
570 let matches = grep_exact_line(host_handler, search_string, &block.file_path).await;
571 if !matches.is_empty() {
572 return Ok(AttributeComplianceAssessment::Compliant);
573 }
574 }
576 }
577
578 let line_content = get_line_content(line, optional_secret_provider).await?;
580 let insert_call = match &block.state {
581 LineExpectedState::Present {
582 position,
583 firstmatch,
584 ..
585 } => {
586 let firstmatch = firstmatch.unwrap_or(false);
587 match position {
588 LinePosition::InsertAfter(pattern) => match pattern.as_str() {
589 "BOF" => LineInFileModuleInternalApiCall::InsertTop {
590 line_content: line_content.clone(),
591 },
592 "EOF" | "" => LineInFileModuleInternalApiCall::InsertBottom {
593 line_content: line_content.clone(),
594 },
595 _ => {
596 let matches =
597 grep_lines(host_handler, pattern, &block.file_path, false).await;
598 if matches.is_empty() {
599 LineInFileModuleInternalApiCall::InsertBottom {
600 line_content: line_content.clone(),
601 }
602 } else {
603 let n = if firstmatch {
604 *matches.first().unwrap()
605 } else {
606 *matches.last().unwrap()
607 };
608 LineInFileModuleInternalApiCall::InsertAfterLine {
609 line_content: line_content.clone(),
610 line_number: n,
611 }
612 }
613 }
614 },
615 LinePosition::InsertBefore(pattern) => match pattern.as_str() {
616 "BOF" => LineInFileModuleInternalApiCall::InsertTop {
617 line_content: line_content.clone(),
618 },
619 _ => {
620 let matches =
621 grep_lines(host_handler, pattern, &block.file_path, false).await;
622 if matches.is_empty() {
623 LineInFileModuleInternalApiCall::InsertBottom {
624 line_content: line_content.clone(),
625 }
626 } else {
627 let n = if firstmatch {
628 *matches.first().unwrap()
629 } else {
630 *matches.last().unwrap()
631 };
632 LineInFileModuleInternalApiCall::InsertBeforeLine {
633 line_content: line_content.clone(),
634 line_number: n,
635 }
636 }
637 }
638 },
639 }
640 }
641 _ => LineInFileModuleInternalApiCall::InsertBottom {
642 line_content: line_content.clone(),
643 },
644 };
645
646 Ok(AttributeComplianceAssessment::NonCompliant(
647 RemediationsList::from(vec![Remediation::LineInFile(LineInFileApiCall {
648 file_path: block.file_path.clone(),
649 line: line.clone(), api_call: insert_call,
651 privilege: privilege.clone(),
652 })])
653 .unwrap(),
654 ))
655}
656
657async fn get_line_content(
659 line: &Line,
660 optional_secret_provider: &Option<SecretProvidersPool>,
661) -> Result<String, RegentError> {
662 match line {
663 Line::Raw(parameter_line) => {
664 parameter_line
665 .clone()
666 .inner_raw(optional_secret_provider)
667 .await
668 }
669 Line::RegexpWithBackrefs {
670 content_to_insert, ..
671 } => Ok(content_to_insert.clone()),
672 Line::Regexp(regexp) => Ok(regexp.clone()),
673 Line::SearchString(search_string) => Ok(search_string.clone()),
674 }
675}
676
677async fn grep_lines<Handler: HostHandler>(
680 host_handler: &mut Handler,
681 pattern: &str,
682 file_path: &str,
683 fixed: bool,
684) -> Vec<u64> {
685 let flag = if fixed { "-nF" } else { "-n" };
686 let result = host_handler
687 .run_command(
688 &format!("grep {} '{}' {}", flag, pattern, file_path),
689 &Privilege::None,
690 )
691 .await
692 .unwrap();
693 if result.return_code != 0 {
694 return Vec::new();
695 }
696 result
697 .stdout
698 .lines()
699 .filter_map(|l| l.split(':').next()?.parse::<u64>().ok())
700 .collect()
701}
702
703async fn grep_exact_line<Handler: HostHandler>(
704 host_handler: &mut Handler,
705 line: &str,
706 file_path: &str,
707) -> Vec<u64> {
708 let result = host_handler
709 .run_command(
710 &format!("grep -nxF '{}' {}", line, file_path),
711 &Privilege::None,
712 )
713 .await
714 .unwrap();
715 if result.return_code != 0 {
716 return Vec::new();
717 }
718 result
719 .stdout
720 .lines()
721 .filter_map(|l| l.split(':').next()?.parse::<u64>().ok())
722 .collect()
723}
724
725async fn get_line<Handler: HostHandler>(
726 host_handler: &mut Handler,
727 line_number: u64,
728 file_path: &str,
729) -> Option<String> {
730 let result = host_handler
731 .run_command(
732 &format!("sed -n '{}p' {}", line_number, file_path),
733 &Privilege::None,
734 )
735 .await
736 .unwrap();
737 if result.return_code == 0 {
738 Some(result.stdout.trim_end_matches('\n').to_string())
739 } else {
740 None
741 }
742}
743
744async fn get_line_count<Handler: HostHandler>(
745 host_handler: &mut Handler,
746 file_path: &str,
747 privilege: &Privilege,
748) -> u64 {
749 host_handler
750 .run_command(&format!("wc -l < {}", file_path), privilege)
751 .await
752 .unwrap()
753 .stdout
754 .trim()
755 .parse::<u64>()
756 .unwrap_or(0)
757}
758
759fn escape_sed_text(s: &str) -> String {
761 s.replace('\\', "\\\\")
762}
763
764fn escape_sed_pattern(s: &str) -> String {
766 s.replace('/', "\\/")
767}
768
769fn escape_sed_replacement_backrefs(s: &str) -> String {
771 s.replace('/', "\\/")
772}
773
774#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
777#[serde(rename_all = "PascalCase")]
778pub enum LineInFileModuleInternalApiCall {
779 InsertTop {
780 line_content: String,
781 },
782 InsertBottom {
783 line_content: String,
784 },
785 InsertAfterLine {
786 line_content: String,
787 line_number: u64,
788 },
789 InsertBeforeLine {
790 line_content: String,
791 line_number: u64,
792 },
793 ReplaceLine {
794 line_content: String,
795 line_number: u64,
796 },
797 ReplaceWithBackrefs {
798 line_content: String,
799 line_number: u64,
800 regexp: String,
801 },
802 DeleteLines {
803 line_numbers: Vec<u64>,
804 },
805 DeleteByRegexp {
806 regexp: String,
807 },
808 CreateFile {
809 line_content: String,
810 },
811}
812
813impl std::fmt::Display for LineInFileModuleInternalApiCall {
814 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
815 match self {
816 LineInFileModuleInternalApiCall::InsertTop { line_content } => {
817 write!(f, "insert line at top: '{}'", line_content)
818 }
819 LineInFileModuleInternalApiCall::InsertBottom { line_content } => {
820 write!(f, "insert line at bottom: '{}'", line_content)
821 }
822 LineInFileModuleInternalApiCall::InsertAfterLine {
823 line_content,
824 line_number,
825 } => {
826 write!(
827 f,
828 "insert line '{}' after line {}",
829 line_content, line_number
830 )
831 }
832 LineInFileModuleInternalApiCall::InsertBeforeLine {
833 line_content,
834 line_number,
835 } => {
836 write!(
837 f,
838 "insert line '{}' before line {}",
839 line_content, line_number
840 )
841 }
842 LineInFileModuleInternalApiCall::ReplaceLine {
843 line_content,
844 line_number,
845 } => {
846 write!(f, "replace line {} with '{}'", line_number, line_content)
847 }
848 LineInFileModuleInternalApiCall::ReplaceWithBackrefs {
849 line_content,
850 line_number,
851 regexp,
852 } => {
853 write!(
854 f,
855 "replace line {} using backrefs with regexp '{}' and content '{}'",
856 line_number, regexp, line_content
857 )
858 }
859 LineInFileModuleInternalApiCall::DeleteLines { line_numbers } => {
860 write!(f, "delete lines {:?}", line_numbers)
861 }
862 LineInFileModuleInternalApiCall::DeleteByRegexp { regexp } => {
863 write!(f, "delete lines matching /{}/", regexp)
864 }
865 LineInFileModuleInternalApiCall::CreateFile { line_content } => {
866 write!(f, "create file with line: '{}'", line_content)
867 }
868 }
869 }
870}
871
872#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
873pub struct LineInFileApiCall {
874 pub file_path: String,
875 pub line: Line, pub api_call: LineInFileModuleInternalApiCall,
877 pub privilege: Privilege,
878}
879
880impl LineInFileApiCall {
881 pub fn display(&self) -> String {
882 format!("{} in {}", self.api_call, self.file_path)
883 }
884}
885
886impl Timeout for LineInFileApiCall {
887 fn default_timeout(&self) -> Duration {
888 Duration::from_secs(1)
889 }
890}
891
892impl Check for LineInFileApiCall {
893 fn check(&self) -> Result<(), RegentError> {
894 Ok(())
895 }
896
897 fn check_host_compatibility(
898 &self,
899 _host_properties: &HostProperties,
900 ) -> Result<(), RegentError> {
901 Ok(())
903 }
904}
905
906impl<Handler: HostHandler> ReachCompliance<Handler> for LineInFileApiCall {
907 async fn call(
908 &self,
909 host_handler: &mut Handler,
910 host_properties: &Option<HostProperties>,
911 optional_secret_provider: &Option<SecretProvidersPool>,
912 ) -> Result<InternalApiCallOutcome, RegentError> {
913 if let Some(props) = host_properties {
915 self.check_host_compatibility(props)?;
916 }
917
918 let cmd: String = match &self.api_call {
919 LineInFileModuleInternalApiCall::InsertBottom { line_content } => {
920 format!(
921 "printf '%s\\n' '{}' >> {}",
922 escape_sed_text(line_content),
923 self.file_path
924 )
925 }
926 LineInFileModuleInternalApiCall::InsertTop { line_content } => {
927 let count = get_line_count(host_handler, &self.file_path, &self.privilege).await;
928 if count == 0 {
929 format!(
930 "printf '%s\\n' '{}' > {}",
931 escape_sed_text(line_content),
932 self.file_path
933 )
934 } else {
935 format!(
936 "sed -i '1i\\{}' {}",
937 escape_sed_text(line_content),
938 self.file_path
939 )
940 }
941 }
942 LineInFileModuleInternalApiCall::InsertAfterLine {
943 line_content,
944 line_number,
945 } => {
946 format!(
947 "sed -i '{}a\\{}' {}",
948 line_number,
949 escape_sed_text(&line_content),
950 self.file_path
951 )
952 }
953 LineInFileModuleInternalApiCall::InsertBeforeLine {
954 line_content,
955 line_number,
956 } => {
957 format!(
958 "sed -i '{}i\\{}' {}",
959 line_number,
960 escape_sed_text(line_content),
961 self.file_path
962 )
963 }
964 LineInFileModuleInternalApiCall::ReplaceLine {
965 line_content,
966 line_number,
967 } => {
968 format!(
969 "sed -i '{}c\\{}' {}",
970 line_number,
971 escape_sed_text(line_content),
972 self.file_path
973 )
974 }
975 LineInFileModuleInternalApiCall::ReplaceWithBackrefs {
976 line_content,
977 line_number,
978 regexp,
979 } => {
980 format!(
981 "sed -i '{} s/{}/{}/' {}",
982 line_number,
983 escape_sed_pattern(regexp),
984 escape_sed_replacement_backrefs(line_content),
985 self.file_path
986 )
987 }
988 LineInFileModuleInternalApiCall::DeleteLines { line_numbers } => {
989 let parts: Vec<String> = line_numbers.iter().map(|n| format!("{}d", n)).collect();
990 format!("sed -i '{}' {}", parts.join(";"), self.file_path)
991 }
992 LineInFileModuleInternalApiCall::DeleteByRegexp { regexp } => {
993 format!(
994 "sed -i '/{}/d' {}",
995 escape_sed_pattern(regexp),
996 self.file_path
997 )
998 }
999 LineInFileModuleInternalApiCall::CreateFile { line_content } => {
1000 if line_content.is_empty() {
1001 format!("touch {}", self.file_path)
1002 } else {
1003 format!(
1004 "printf '%s\\n' '{}' > {}",
1005 escape_sed_text(line_content),
1006 self.file_path
1007 )
1008 }
1009 }
1010 };
1011
1012 let result = host_handler
1013 .run_command(cmd.as_str(), &self.privilege)
1014 .await
1015 .unwrap();
1016
1017 if result.return_code == 0 {
1018 let verification_result = verify_file_state(
1020 host_handler,
1021 &self.file_path,
1022 &self.api_call,
1023 &self.privilege,
1024 )
1025 .await;
1026
1027 if verification_result {
1028 Ok(InternalApiCallOutcome::Success(None))
1029 } else {
1030 Ok(InternalApiCallOutcome::Failure(
1031 "Command succeeded but post-verification failed: file state does not match expected".to_string(),
1032 ))
1033 }
1034 } else {
1035 Ok(InternalApiCallOutcome::Failure(format!(
1036 "RC: {}, STDOUT: {}, STDERR: {}",
1037 result.return_code, result.stdout, result.stderr
1038 )))
1039 }
1040 }
1041}
1042
1043async fn verify_file_state<Handler: HostHandler>(
1046 host_handler: &mut Handler,
1047 file_path: &str,
1048 api_call: &LineInFileModuleInternalApiCall,
1049 privilege: &Privilege,
1050) -> bool {
1051 match api_call {
1052 LineInFileModuleInternalApiCall::InsertTop { line_content } => {
1053 if let Some(content) = read_file_content(host_handler, file_path, privilege).await {
1055 content
1056 .lines()
1057 .next()
1058 .map_or(false, |first_line| first_line.trim() == line_content.trim())
1059 } else {
1060 false
1061 }
1062 }
1063 LineInFileModuleInternalApiCall::InsertBottom { line_content } => {
1064 if let Some(content) = read_file_content(host_handler, file_path, privilege).await {
1066 content
1067 .lines()
1068 .last()
1069 .map_or(false, |last_line| last_line.trim() == line_content.trim())
1070 } else {
1071 false
1072 }
1073 }
1074 LineInFileModuleInternalApiCall::InsertAfterLine {
1075 line_content,
1076 line_number,
1077 } => {
1078 if let Some(content) = read_file_content(host_handler, file_path, privilege).await {
1080 let lines: Vec<&str> = content.lines().collect();
1081 let insert_pos = *line_number as usize;
1082 if insert_pos < lines.len() {
1083 let next_pos = insert_pos + 1;
1084 if next_pos < lines.len() {
1085 return lines[next_pos].trim() == line_content.trim();
1086 }
1087 }
1088 false
1089 } else {
1090 false
1091 }
1092 }
1093 LineInFileModuleInternalApiCall::InsertBeforeLine {
1094 line_content,
1095 line_number,
1096 } => {
1097 if let Some(content) = read_file_content(host_handler, file_path, privilege).await {
1099 let lines: Vec<&str> = content.lines().collect();
1100 let insert_pos = *line_number as usize;
1101 if insert_pos > 0 && insert_pos <= lines.len() {
1102 return lines[insert_pos - 1].trim() == line_content.trim();
1103 }
1104 false
1105 } else {
1106 false
1107 }
1108 }
1109 LineInFileModuleInternalApiCall::ReplaceLine {
1110 line_content,
1111 line_number,
1112 } => {
1113 if let Some(content) = read_file_content(host_handler, file_path, privilege).await {
1115 let lines: Vec<&str> = content.lines().collect();
1116 let pos = *line_number as usize;
1117 if pos > 0 && pos <= lines.len() {
1118 return lines[pos - 1].trim() == line_content.trim();
1119 }
1120 false
1121 } else {
1122 false
1123 }
1124 }
1125 LineInFileModuleInternalApiCall::ReplaceWithBackrefs {
1126 line_content,
1127 line_number,
1128 regexp,
1129 } => {
1130 if let Some(content) = read_file_content(host_handler, file_path, privilege).await {
1132 let lines: Vec<&str> = content.lines().collect();
1133 let pos = *line_number as usize;
1134 if pos > 0 && pos <= lines.len() {
1135 return lines[pos - 1].trim() == line_content.trim();
1136 }
1137 false
1138 } else {
1139 false
1140 }
1141 }
1142 LineInFileModuleInternalApiCall::DeleteLines { line_numbers } => {
1143 if let Some(content) = read_file_content(host_handler, file_path, privilege).await {
1145 let lines: Vec<&str> = content.lines().collect();
1146 for &n in line_numbers {
1148 let pos = n as usize;
1149 if pos > 0 && pos <= lines.len() {
1150 return false; }
1152 }
1153 true
1154 } else {
1155 false
1156 }
1157 }
1158 LineInFileModuleInternalApiCall::DeleteByRegexp { regexp } => {
1159 if let Some(content) = read_file_content(host_handler, file_path, privilege).await {
1161 !content.lines().any(|line| line.contains(regexp.as_str()))
1162 } else {
1163 false
1164 }
1165 }
1166 LineInFileModuleInternalApiCall::CreateFile { line_content } => {
1167 if let Some(content) = read_file_content(host_handler, file_path, privilege).await {
1169 content
1170 .lines()
1171 .next()
1172 .map_or(false, |first_line| first_line.trim() == line_content.trim())
1173 } else {
1174 false
1175 }
1176 }
1177 }
1178}
1179
1180async fn read_file_content<Handler: HostHandler>(
1182 host_handler: &mut Handler,
1183 file_path: &str,
1184 privilege: &Privilege,
1185) -> Option<String> {
1186 let result = host_handler
1187 .run_command(&format!("cat {}", file_path), privilege)
1188 .await
1189 .unwrap();
1190 if result.return_code == 0 {
1191 Some(result.stdout)
1192 } else {
1193 None
1194 }
1195}
1196
1197use std::assert_matches;
1198
1199#[cfg(test)]
1200mod tests {
1201 use super::*;
1202
1203 #[test]
1204 fn parsing_lineinfile_module_block_from_yaml_str() {
1205 let raw = "---
1206- FilePath: /etc/hosts
1207 Line: !Raw '192.168.1.10 myhost'
1208 State: !Present
1209
1210- FilePath: /etc/hosts
1211 Line: !Raw '192.168.1.10 myhost'
1212 State: !Present
1213 Position: !InsertAfter '^127\\.0\\.0\\.1'
1214
1215- FilePath: /etc/hosts
1216 Line: !Raw '# managed block'
1217 State: !Present
1218 Position: !InsertBefore BOF
1219
1220- FilePath: /etc/sysctl.conf
1221 Line: !Regexp '^net\\.ipv4\\.ip_forward'
1222 State: !Present
1223
1224- FilePath: /etc/sysctl.conf
1225 Line: !RegexpWithBackrefs
1226 Regexp: '^(net\\.ipv4\\.ip_forward)\\s*=.*'
1227 ContentToInsert: '\\1 = 1'
1228 State: !Present
1229
1230- FilePath: /etc/hosts
1231 Line: !Regexp '^192\\.168\\.1\\.10'
1232 State: !Absent
1233
1234- FilePath: /etc/motd
1235 Line: !Raw 'welcome'
1236 State: !Present
1237 Create: true
1238 ";
1239 let _: Vec<LineInFileBlockExpectedState> = yaml_serde::from_str(raw).unwrap();
1240 }
1241 #[test]
1242 fn test_deserialize_line_in_file_block_expected_state_present_raw() {
1243 let yaml = r#"
1244 FilePath: /tmp/test.txt
1245 State: !Present
1246 Position: !InsertAfter EOF
1247 Firstmatch: true
1248 Create: false
1249 Line: !Raw
1250 "hello world"
1251 "#;
1252
1253 let result: LineInFileBlockExpectedState = yaml_serde::from_str(yaml).unwrap();
1254 assert_eq!(result.file_path, "/tmp/test.txt");
1255 assert_matches!(
1256 result.state,
1257 LineExpectedState::Present {
1258 position: LinePosition::InsertAfter(_),
1259 firstmatch: Some(true),
1260 create: Some(false),
1261 }
1262 );
1263 assert_matches!(result.line, Line::Raw(_));
1264 }
1265
1266 #[test]
1267 fn test_deserialize_line_in_file_block_expected_state_absent() {
1268 let yaml = r#"
1269 FilePath: /tmp/test.txt
1270 State: !Absent
1271 FileMustExistAnyway: false
1272 Line: !Raw
1273 "hello world"
1274 "#;
1275
1276 let result: LineInFileBlockExpectedState = yaml_serde::from_str(yaml).unwrap();
1277 assert_eq!(result.file_path, "/tmp/test.txt");
1278 assert_matches!(
1279 result.state,
1280 LineExpectedState::Absent {
1281 file_must_exist_anyway: false,
1282 }
1283 );
1284 assert_matches!(result.line, Line::Raw(_));
1285 }
1286
1287 #[test]
1288 fn test_deserialize_line_in_file_block_expected_state_regexp() {
1289 let yaml = r#"
1290 FilePath: /tmp/test.txt
1291 State: !Present
1292 Position: !InsertBefore BOF
1293 Firstmatch: false
1294 Create: true
1295 Line: !Regexp
1296 "^hello.*$"
1297 "#;
1298
1299 let result: LineInFileBlockExpectedState = yaml_serde::from_str(yaml).unwrap();
1300 assert_eq!(result.file_path, "/tmp/test.txt");
1301 assert_matches!(
1302 result.state,
1303 LineExpectedState::Present {
1304 position: LinePosition::InsertBefore(_),
1305 firstmatch: Some(false),
1306 create: Some(true),
1307 }
1308 );
1309 assert_matches!(result.line, Line::Regexp(_));
1310 }
1311
1312 #[test]
1313 fn test_deserialize_line_in_file_block_expected_state_regexp_with_backrefs() {
1314 let yaml = r#"
1315 FilePath: /tmp/test.txt
1316 State: !Present
1317 Line: !RegexpWithBackrefs
1318 Regexp: "^hello (\\w+)$"
1319 ContentToInsert: "hi \\1"
1320 "#;
1321
1322 let result: LineInFileBlockExpectedState = yaml_serde::from_str(yaml).unwrap();
1323 assert_eq!(result.file_path, "/tmp/test.txt");
1324 assert_matches!(result.state, LineExpectedState::Present { .. });
1325 assert_matches!(
1326 result.line,
1327 Line::RegexpWithBackrefs {
1328 regexp: _,
1329 content_to_insert: _,
1330 }
1331 );
1332 }
1333
1334 #[test]
1335 fn test_deserialize_line_in_file_block_expected_state_search_string() {
1336 let yaml = r#"
1337 FilePath: /tmp/test.txt
1338 State: !Present
1339 Line: !SearchString
1340 "hello world"
1341 "#;
1342
1343 let result: LineInFileBlockExpectedState = yaml_serde::from_str(yaml).unwrap();
1344 assert_eq!(result.file_path, "/tmp/test.txt");
1345 assert_matches!(result.state, LineExpectedState::Present { .. });
1346 assert_matches!(result.line, Line::SearchString(_));
1347 }
1348
1349 #[test]
1350 fn test_deserialize_line_in_file_block_expected_state_position_bof() {
1351 let yaml = r#"
1352 FilePath: /tmp/test.txt
1353 State: !Present
1354 Position: !InsertBefore
1355 BOF
1356 Line: !Raw
1357 "hello world"
1358 "#;
1359
1360 let result: LineInFileBlockExpectedState = yaml_serde::from_str(yaml).unwrap();
1361 assert_matches!(
1362 result.state,
1363 LineExpectedState::Present {
1364 position: LinePosition::InsertBefore(_),
1365 ..
1366 }
1367 );
1368 }
1369
1370 #[test]
1371 fn test_deserialize_line_in_file_block_expected_state_position_eof() {
1372 let yaml = r#"
1373 FilePath: /tmp/test.txt
1374 State: !Present
1375 Position: !InsertAfter EOF
1376 Line: !Raw
1377 "hello world"
1378 "#;
1379
1380 let result: LineInFileBlockExpectedState = yaml_serde::from_str(yaml).unwrap();
1381 assert_matches!(
1382 result.state,
1383 LineExpectedState::Present {
1384 position: LinePosition::InsertAfter(_),
1385 ..
1386 }
1387 );
1388 }
1389
1390 #[test]
1391 fn test_deserialize_line_in_file_block_expected_state_minimal() {
1392 let yaml = r#"
1394 FilePath: /tmp/test.txt
1395 Line: !Raw "hello world"
1396 "#;
1397
1398 let result = yaml_serde::from_str::<LineInFileBlockExpectedState>(yaml);
1399 assert!(result.is_err()); }
1401
1402 #[test]
1403 fn test_deserialize_line_in_file_block_expected_state_invalid() {
1404 let yaml = r#"
1406 FilePath: /tmp/test.txt
1407 UnknownField: true
1408 Line: !Raw
1409 "hello world"
1410 "#;
1411
1412 let result = yaml_serde::from_str::<LineInFileBlockExpectedState>(yaml);
1413 assert!(result.is_err()); }
1415
1416 #[test]
1417 fn test_line_in_file_block_expected_state_defaults() {
1418 let yaml = r#"
1420 FilePath: /tmp/test.txt
1421 State: !Present
1422 Line: !Raw
1423 "hello world"
1424 "#;
1425
1426 let result: LineInFileBlockExpectedState = yaml_serde::from_str(yaml).unwrap();
1427 assert_matches!(
1428 result.state,
1429 LineExpectedState::Present {
1430 position: _,
1431 firstmatch: None,
1432 create: None,
1433 }
1434 );
1435 }
1436}