Skip to main content

regent_sdk/state/attribute/utilities/
lineinfile.rs

1//! Line in file management attribute
2//!
3//! This module provides the `LineInFileExpectedState` type for ensuring specific lines
4//! are present or absent in files. Useful for configuration files, environment files, etc.
5//!
6//! **Compatible OS:** All (cross-platform)
7//!
8//! # Examples
9//!
10//! ## Rust API
11//!
12//! ```no_run
13//! use regent_sdk::state::attribute::utilities::lineinfile::LineInFileExpectedState;
14//! use regent_sdk::{Attribute, ExpectedState, Privilege};
15//!
16//! // Ensure a line exists in /etc/environment, creating the file if needed
17//! let env_line = LineInFileExpectedState::present_at_bottom(
18//!     "/etc/environment",
19//!     LineInFileExpectedState::raw_line_from_str("KEY=value"),
20//!     Some(true),
21//! );
22//!
23//! // Ensure no line matching a regex remains in /etc/hosts
24//! let old_host = LineInFileExpectedState::absent(
25//!     "/etc/hosts",
26//!     LineInFileExpectedState::regexp_line(r"^192\.168\.1\.10\s"),
27//!     false,
28//! );
29//!
30//! let expected_state = ExpectedState::new()
31//!     .with_attribute(Attribute::lineinfile(env_line, Privilege::WithSudo, None))
32//!     .with_attribute(Attribute::lineinfile(old_host, Privilege::WithSudo, None))
33//!     .build();
34//! ```
35//!
36//! ## YAML API
37//!
38//! ```yaml
39//! Attributes:
40//!   - Name: KEY=value must be present in /etc/environment
41//!     Privilege: !WithSudo
42//!     Detail: !LineInFile
43//!       FilePath: /etc/environment
44//!       State: !Present
45//!         Create: true
46//!       Line: !Raw "KEY=value"
47//! ```
48
49use 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        /// When using InsertAfter/InsertBefore, match the first occurrence instead of the last.
74        firstmatch: Option<bool>,
75        /// Create the file if it does not exist (default: false).
76        create: Option<bool>,
77    },
78    #[serde(rename_all = "PascalCase")]
79    Absent {
80        /// Line must be absent but file must be exists anyway. If set to false, no error will be triggered when trying to remove a line from a non-existing file.
81        #[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    /// The exact line to insert, or the replacement when regexp/search_string matches.
90    Raw(Parameter<String>),
91    /// Regex that selects the line(s) to replace (last match) or delete. Mutually exclusive with SearchString.
92    Regexp(String),
93    #[serde(rename_all = "PascalCase")]
94    RegexpWithBackrefs {
95        /// The regex to search for. You must include capture groups using parentheses () so the line parameter can reference them.
96        regexp: String,
97        /// The content to insert. It must contain backreferences like \1, \2, or \g<1> to pull the captured text from the regexp match.
98        content_to_insert: String,
99    },
100    /// Fixed-string alternative to Regexp for matching whole lines. Mutually exclusive with Regexp.
101    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    /// Insert the line after the last line matching this regex, or after "EOF" / "BOF". Mutually exclusive with InsertBefore.
122    InsertAfter(String),
123    /// Insert the line before the last line matching this regex, or before "BOF". Mutually exclusive with InsertAfter.
124    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    /// Absolute path to the managed file.
136    file_path: String,
137    /// Line to ensure is present or absent. Default state: Present.
138    state: LineExpectedState,
139    line: Line,
140}
141
142impl LineInFileExpectedState {
143    // --- Absent State ---
144
145    /// Creates a block for a line that should **not** exist in the file.
146    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    // --- Present State ---
161
162    /// Creates a block for a line that should exist at the **top of the file**.
163    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    /// Creates a block for a line that should exist at the **bottom of the file**.
180    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    /// Creates a block for a line that should exist **after a specific pattern**.
197    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    /// Creates a block for a line that should exist **before a specific pattern**.
216    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    /// Creates a block for a line that should exist **somewhere in the file** (no position constraint).
235    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    // --- Helper Methods for Line Construction ---
252
253    /// Helper to create a `Line::Raw` from a string.
254    pub fn raw_line(content: Parameter<String>) -> Line {
255        Line::Raw(content)
256    }
257
258    /// Helper to create a `Line::Raw` from a clear (non-secret) string.
259    pub fn raw_line_from_str(content: &str) -> Line {
260        Line::Raw(Parameter::Clear(content.to_string()))
261    }
262
263    /// Helper to create a `Line::Raw` from a secret reference.
264    pub fn raw_line_from_secret(sec_ref: SecretReference) -> Line {
265        Line::Raw(Parameter::Secret(sec_ref))
266    }
267
268    /// Helper to create a `Line::Regexp` from a regex pattern.
269    pub fn regexp_line(pattern: &str) -> Line {
270        Line::Regexp(pattern.to_string())
271    }
272
273    /// Helper to create a `Line::RegexpWithBackrefs` from a regex and replacement.
274    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    /// Helper to create a `Line::SearchString` from a literal string.
282    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        // Line in file operations are cross-platform compatible
302        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    // Determine insert position
561    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
639/// Helper function to extract line content from `Line` enum
640async 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
659// ── helpers ──────────────────────────────────────────────────────────────────
660
661async 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
742/// Escape content for use inside a sed `i\`, `a\`, or `c\` command (backslash only).
743fn escape_sed_text(s: &str) -> String {
744    s.replace('\\', "\\\\")
745}
746
747/// Escape a string for use as a sed address pattern (between `/…/`).
748fn escape_sed_pattern(s: &str) -> String {
749    s.replace('/', "\\/")
750}
751
752/// Escape a sed replacement string where backreferences must be preserved.
753fn escape_sed_replacement_backrefs(s: &str) -> String {
754    s.replace('/', "\\/")
755}
756
757// ── API call types ────────────────────────────────────────────────────────────
758
759#[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        // Line in file operations are cross-platform compatible
885        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            // Post-application verification: verify the change was actually applied
1001            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
1025/// Verify that the file state matches the expected state after a modification.
1026/// This implements post-application verification for idempotency.
1027async 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            // Verify the line exists at the beginning of the file
1036            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            // Verify the line exists at the end of the file
1047            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            // Verify the line exists after the specified line number
1061            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            // Verify the line exists before the specified line number
1080            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            // Verify the line at position `line_number` matches the expected line
1096            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            // For backrefs, check that the line exists at the expected position
1113            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            // Verify the lines were actually deleted
1126            if let Some(content) = read_file_content(host_handler, file_path, privilege).await {
1127                let lines: Vec<&str> = content.lines().collect();
1128                // Check that none of the deleted line numbers exist
1129                for &n in line_numbers {
1130                    let pos = n as usize;
1131                    if pos > 0 && pos <= lines.len() {
1132                        return false; // Line still exists
1133                    }
1134                }
1135                true
1136            } else {
1137                false
1138            }
1139        }
1140        LineInFileModuleInternalApiCall::DeleteByRegexp { regexp } => {
1141            // Verify no lines matching the pattern exist
1142            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            // Verify the file exists and contains the expected line
1150            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
1162/// Read the content of a file from the remote host.
1163async 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        // Test minimal YAML (all optional fields omitted)
1374        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()); // Missing required State field
1381    }
1382
1383    #[test]
1384    fn test_deserialize_line_in_file_block_expected_state_invalid() {
1385        // Test invalid YAML (e.g., unknown fields at top level)
1386        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()); // Should fail due to unknown field at top level
1395    }
1396
1397    #[test]
1398    fn test_line_in_file_block_expected_state_defaults() {
1399        // Test default values for optional fields
1400        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}