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