Skip to main content

regent_sdk/state/attribute/utilities/
lineinfile.rs

1//! Line in file management attribute
2//!
3//! This module provides the `LineInFileBlockExpectedState` 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::{LineInFileBlockExpectedState, LineExpectedState};
14//! use regent_sdk::{Attribute, ExpectedState, Privilege};
15//!
16//! // Ensure a line exists in /etc/environment
17//! let env_line = LineInFileBlockExpectedState::builder("/etc/environment")
18//!     .with_state(LineExpectedState::Present)
19//!     .with_line("KEY=value")
20//!     .with_create(true)
21//!     .build()
22//!     .unwrap();
23//!
24//! let expected_state = ExpectedState::new()
25//!     .with_attribute(Attribute::lineinfile(env_line, Privilege::WithSudo, None))
26//!     .build();
27//! ```
28//!
29//! ## YAML API
30//!
31//! ```yaml
32//! Attributes:
33//!   - Name: KEY=value must be present in /etc/environment
34//!     Privilege: !WithSudo
35//!     Detail: !LineInFile
36//!       FilePath: /etc/environment
37//!       State: !Present
38//!         Create: true
39//!       Line: !Raw "KEY=value"
40//! ```
41
42use 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/// Desired state of a line in a file
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61#[serde(rename_all = "PascalCase")]
62pub enum LineExpectedState {
63    /// Line should exist in the file
64    #[serde(rename_all = "PascalCase")]
65    Present {
66        #[serde(default = "default_line_position")]
67        position: LinePosition,
68        /// When using InsertAfter/InsertBefore, match the first occurrence instead of the last.
69        firstmatch: Option<bool>,
70        /// Create the file if it does not exist (default: false).
71        create: Option<bool>,
72    },
73    /// Line should not exist in the file
74    #[serde(rename_all = "PascalCase")]
75    Absent {
76        /// 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.
77        #[serde(default)] // -> false
78        file_must_exist_anyway: bool,
79    },
80}
81
82#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
83#[serde(rename_all = "PascalCase")]
84pub enum Line {
85    /// The exact line to insert, or the replacement when regexp/search_string matches.
86    Raw(Parameter<String>),
87    /// Regex that selects the line(s) to replace (last match) or delete. Mutually exclusive with SearchString.
88    Regexp(String),
89    #[serde(rename_all = "PascalCase")]
90    RegexpWithBackrefs {
91        /// The regex to search for. You must include capture groups using parentheses () so the line parameter can reference them.
92        regexp: String,
93        /// The content to insert. It must contain backreferences like \1, \2, or \g<1> to pull the captured text from the regexp match.
94        content_to_insert: String,
95    },
96    /// Fixed-string alternative to Regexp for matching whole lines. Mutually exclusive with Regexp.
97    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    /// Insert the line after the last line matching this regex, or after "EOF" / "BOF". Mutually exclusive with InsertBefore.
118    InsertAfter(String),
119    /// Insert the line before the last line matching this regex, or before "BOF". Mutually exclusive with InsertAfter.
120    InsertBefore(String),
121}
122
123fn default_line_position() -> LinePosition {
124    LinePosition::InsertAfter("EOF".to_string())
125}
126
127/// Configuration for managing a line in a file
128#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
129#[serde(deny_unknown_fields)]
130#[serde(rename_all = "PascalCase")]
131pub struct LineInFileBlockExpectedState {
132    /// Absolute path to the managed file.
133    file_path: String,
134    /// Line to ensure is present or absent. Default state: Present.
135    state: LineExpectedState,
136    line: Line,
137}
138
139impl LineInFileBlockExpectedState {
140    // --- Absent State ---
141
142    /// Creates a block for a line that should **not** exist in the file.
143    /// - `file_path`: Path to the file.
144    /// - `line`: The line (or pattern) to ensure is absent.
145    /// - `file_must_exist_anyway`: If `true`, the file must exist (but the line must not).
146    ///   If `false`, the file may not exist (and the line is trivially absent).
147    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    // --- Present State ---
162
163    /// Creates a block for a line that should exist at the **top of the file**.
164    /// - `file_path`: Path to the file.
165    /// - `line`: The line to insert (or ensure exists).
166    /// - `create`: If `true`, the file will be created if it doesn’t exist.
167    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    /// Creates a block for a line that should exist at the **bottom of the file**.
184    /// - `file_path`: Path to the file.
185    /// - `line`: The line to insert (or ensure exists).
186    /// - `create`: If `true`, the file will be created if it doesn’t exist.
187    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    /// Creates a block for a line that should exist **after a specific pattern**.
204    /// - `file_path`: Path to the file.
205    /// - `line`: The line to insert (or ensure exists).
206    /// - `insert_after`: The pattern to match (e.g., a regex or literal string).
207    /// - `firstmatch`: If `true`, use the first match of `insert_after`. If `false`, use the last match.
208    /// - `create`: If `true`, the file will be created if it doesn’t exist.
209    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    /// Creates a block for a line that should exist **before a specific pattern**.
228    /// - `file_path`: Path to the file.
229    /// - `line`: The line to insert (or ensure exists).
230    /// - `insert_before`: The pattern to match (e.g., a regex or literal string).
231    /// - `firstmatch`: If `true`, use the first match of `insert_before`. If `false`, use the last match.
232    /// - `create`: If `true`, the file will be created if it doesn’t exist.
233    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    /// Creates a block for a line that should exist **somewhere in the file** (no position constraint).
252    /// - `file_path`: Path to the file.
253    /// - `line`: The line to ensure exists.
254    /// - `create`: If `true`, the file will be created if it doesn’t exist.
255    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()), // Default to EOF
264                firstmatch: None,
265                create,
266            },
267            line,
268        }
269    }
270
271    // --- Helper Methods for Line Construction ---
272
273    /// Helper to create a `Line::Raw` from a string.
274    pub fn raw_line(content: Parameter<String>) -> Line {
275        Line::Raw(content)
276    }
277
278    /// Helper to create a `Line::Regexp` from a regex pattern.
279    pub fn regexp_line(pattern: &str) -> Line {
280        Line::Regexp(pattern.to_string())
281    }
282
283    /// Helper to create a `Line::RegexpWithBackrefs` from a regex and replacement.
284    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    /// Helper to create a `Line::SearchString` from a literal string.
292    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        // Line in file operations are cross-platform compatible
312        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        // Early checks (unchanged)
331        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                        // File does not exist and must be created
393                        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(), // Preserve `Line`
404                                            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, // Now uses `Line` directly
434    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(), // Preserve `Line`
450                    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(), // Preserve `Line`
467                    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(), // Preserve `Line`
487                    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(), // Preserve `Line`
504                    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, // Now uses `Line` directly
520    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            // Fall through to insert
534        }
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            // Fall through to insert
541        }
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(), // Preserve `Line`
557                        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            // Fall through to insert
568        }
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            // Fall through to insert
575        }
576    }
577
578    // Determine insert position
579    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(), // Preserve `Line`
650            api_call: insert_call,
651            privilege: privilege.clone(),
652        })])
653        .unwrap(),
654    ))
655}
656
657/// Helper function to extract line content from `Line` enum
658async 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
677// ── helpers ──────────────────────────────────────────────────────────────────
678
679async 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
759/// Escape content for use inside a sed `i\`, `a\`, or `c\` command (backslash only).
760fn escape_sed_text(s: &str) -> String {
761    s.replace('\\', "\\\\")
762}
763
764/// Escape a string for use as a sed address pattern (between `/…/`).
765fn escape_sed_pattern(s: &str) -> String {
766    s.replace('/', "\\/")
767}
768
769/// Escape a sed replacement string where backreferences must be preserved.
770fn escape_sed_replacement_backrefs(s: &str) -> String {
771    s.replace('/', "\\/")
772}
773
774// ── API call types ────────────────────────────────────────────────────────────
775
776#[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, // Preserve `Line` (which may contain `Parameter<String>`)
876    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        // Line in file operations are cross-platform compatible
902        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        // Early check: verify host compatibility (always passes for lineinfile)
914        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            // Post-application verification: verify the change was actually applied
1019            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
1043/// Verify that the file state matches the expected state after a modification.
1044/// This implements post-application verification for idempotency.
1045async 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            // Verify the line exists at the beginning of the file
1054            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            // Verify the line exists at the end of the file
1065            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            // Verify the line exists after the specified line number
1079            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            // Verify the line exists before the specified line number
1098            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            // Verify the line at position `line_number` matches the expected line
1114            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            // For backrefs, check that the line exists at the expected position
1131            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            // Verify the lines were actually deleted
1144            if let Some(content) = read_file_content(host_handler, file_path, privilege).await {
1145                let lines: Vec<&str> = content.lines().collect();
1146                // Check that none of the deleted line numbers exist
1147                for &n in line_numbers {
1148                    let pos = n as usize;
1149                    if pos > 0 && pos <= lines.len() {
1150                        return false; // Line still exists
1151                    }
1152                }
1153                true
1154            } else {
1155                false
1156            }
1157        }
1158        LineInFileModuleInternalApiCall::DeleteByRegexp { regexp } => {
1159            // Verify no lines matching the pattern exist
1160            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            // Verify the file exists and contains the expected line
1168            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
1180/// Read the content of a file from the remote host.
1181async 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        // Test minimal YAML (all optional fields omitted)
1393        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()); // Missing required State field
1400    }
1401
1402    #[test]
1403    fn test_deserialize_line_in_file_block_expected_state_invalid() {
1404        // Test invalid YAML (e.g., unknown fields at top level)
1405        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()); // Should fail due to unknown field at top level
1414    }
1415
1416    #[test]
1417    fn test_line_in_file_block_expected_state_defaults() {
1418        // Test default values for optional fields
1419        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}