Skip to main content

regent_sdk/state/attribute/utilities/
lineinfile.rs

1use crate::error::RegentError;
2use crate::hosts::managed_host::InternalApiCallOutcome;
3use crate::hosts::managed_host::{AssessCompliance, ReachCompliance};
4use crate::hosts::properties::HostProperties;
5use crate::secrets::SecretProvidersPool;
6use crate::state::Check;
7use crate::state::attribute::HostHandler;
8use crate::state::attribute::Privilege;
9use crate::state::attribute::Remediation;
10use crate::state::compliance::AttributeComplianceAssessment;
11use crate::state::expected_state::Parameter;
12use serde::{Deserialize, Serialize};
13
14#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
15#[serde(rename_all = "PascalCase")]
16pub enum LineInFileModuleInternalApiCall {
17    Add(LineExpectedPosition),
18    Delete(Vec<u64>),
19}
20
21impl std::fmt::Display for LineInFileModuleInternalApiCall {
22    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23        match self {
24            LineInFileModuleInternalApiCall::Add(position) => {
25                write!(f, "add line at position {:?}", position)
26            }
27            LineInFileModuleInternalApiCall::Delete(line_numbers) => {
28                write!(f, "delete lines {:?}", line_numbers)
29            }
30        }
31    }
32}
33
34#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35#[serde(rename_all = "PascalCase")]
36enum LineExpectedState {
37    Present,
38    Absent,
39}
40
41#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
42#[serde(rename_all = "PascalCase")]
43pub enum LineExpectedPosition {
44    Top,
45    Bottom,
46    Anywhere,
47    // #[serde(untagged)]
48    LineNumber(u64),
49}
50
51#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
52#[serde(deny_unknown_fields)]
53#[serde(rename_all = "PascalCase")]
54pub struct LineInFileBlockExpectedState {
55    file_path: String,
56    line: Option<Parameter<String>>,
57    state: LineExpectedState,
58    position: Option<LineExpectedPosition>, // "top" | "bottom" | "anywhere" (default) | "45" (specific line number)
59    line_number: Option<u64>, // Exists to avoid weird YAML writing for LineExpectedPosition::LineNumber(u64)
60
61                              // ****** To be implemented ********
62                              // beforeline: Option<String>, // Insert before this line
63                              // afterline: Option<String>, // Insert after this line
64                              // replace: Option<String>, // Replace this line...
65                              // with: Option<String> // ... with this one.
66}
67
68impl Check for LineInFileBlockExpectedState {
69    fn check(&self) -> Result<(), RegentError> {
70        if let (None, None) = (&self.line, &self.position) {
71            return Err(RegentError::IncoherentExpectedState(format!(
72                "Both 'line' and 'position' are unset. What is the expected state of this file ({}) ?",
73                self.file_path
74            )));
75        }
76        Ok(())
77    }
78}
79
80impl<Handler: HostHandler> AssessCompliance<Handler> for LineInFileBlockExpectedState {
81    async fn assess_compliance(
82        &self,
83        host_handler: &mut Handler,
84        _host_properties: &Option<HostProperties>,
85        privilege: &Privilege,
86        optional_secret_provider: &Option<SecretProvidersPool>,
87    ) -> Result<AttributeComplianceAssessment, RegentError> {
88        if !host_handler
89            .is_this_command_available("sed", &privilege)
90            .unwrap()
91        {
92            return Err(RegentError::FailedDryRunEvaluation(
93                "Sed command not available on this host".to_string(),
94            ));
95        }
96
97        let privilege = privilege.clone();
98
99        let file_exists_check = host_handler
100            .run_command(format!("test -f {}", self.file_path).as_str(), &privilege)
101            .unwrap();
102
103        if file_exists_check.return_code != 0 {
104            return Err(RegentError::FailedDryRunEvaluation(format!(
105                "{} not found, access denied or not a regular file (directory or device ?)",
106                self.file_path
107            )));
108        }
109
110        let line_content: Option<String> = match self.line.clone() {
111            Some(parameter) => Some(parameter.inner_raw(optional_secret_provider).await.unwrap()),
112            None => None,
113        };
114
115        let remediation = match &self.state {
116            LineExpectedState::Present => {
117                let filenumberoflines = host_handler
118                    .run_command(
119                        format!("wc -l {} | cut -f 1 -d ' '", self.file_path).as_str(),
120                        &privilege,
121                    )
122                    .unwrap()
123                    .stdout
124                    .trim()
125                    .parse::<u64>()
126                    .unwrap();
127
128                // Precheck
129                let parsed_expected_position = match self.line_number {
130                    Some(line_number) => Some(LineExpectedPosition::LineNumber(line_number)),
131                    None => self.position.clone(),
132                };
133
134                if let Some(LineExpectedPosition::LineNumber(expected_line_number)) =
135                    parsed_expected_position
136                {
137                    if expected_line_number > filenumberoflines {
138                        return Err(RegentError::FailedDryRunEvaluation(
139                            "Position value out of range (use \"bottom\" instead)".to_string(),
140                        ));
141                    }
142                }
143
144                let file_actual_compliance = is_line_present(
145                    host_handler,
146                    &line_content.clone().unwrap(),
147                    &self.file_path,
148                    &privilege,
149                );
150
151                match file_actual_compliance {
152                    Some(actual_line_numbers) => {
153                        // Line is already there but we need to make sure it is at the expected place
154                        match &parsed_expected_position {
155                            Some(expected_position) => {
156                                match expected_position {
157                                    LineExpectedPosition::Top => {
158                                        if actual_line_numbers.contains(&1) {
159                                            // Line is already at the right place, nothing to do
160                                            Remediation::None(String::from(
161                                                "Line already present at expected place",
162                                            ))
163                                        } else {
164                                            Remediation::LineInFile(LineInFileApiCall {
165                                                api_call: LineInFileModuleInternalApiCall::Add(
166                                                    LineExpectedPosition::Top,
167                                                ),
168                                                line_content: self.line.clone(),
169                                                file_path: self.file_path.clone(),
170                                                privilege,
171                                            })
172                                        }
173                                    }
174                                    LineExpectedPosition::Bottom => {
175                                        if actual_line_numbers.contains(&filenumberoflines) {
176                                            // Line is already at the right place, nothing to do
177                                            Remediation::None(String::from(
178                                                "Line already present at expected place",
179                                            ))
180                                        } else {
181                                            Remediation::LineInFile(LineInFileApiCall {
182                                                api_call: LineInFileModuleInternalApiCall::Add(
183                                                    LineExpectedPosition::Bottom,
184                                                ),
185                                                line_content: self.line.clone(),
186                                                file_path: self.file_path.clone(),
187                                                privilege,
188                                            })
189                                        }
190                                    }
191                                    LineExpectedPosition::LineNumber(specific_line_number) => {
192                                        if actual_line_numbers.contains(&specific_line_number) {
193                                            // Line is already at the right place, nothing to do
194                                            Remediation::None(String::from(
195                                                "Line already present at expected place",
196                                            ))
197                                        } else {
198                                            Remediation::LineInFile(LineInFileApiCall {
199                                                api_call: LineInFileModuleInternalApiCall::Add(
200                                                    LineExpectedPosition::LineNumber(
201                                                        *specific_line_number,
202                                                    ),
203                                                ),
204                                                line_content: self.line.clone(),
205                                                file_path: self.file_path.clone(),
206                                                privilege,
207                                            })
208                                        }
209                                    }
210                                    LineExpectedPosition::Anywhere => {
211                                        if actual_line_numbers.len() != 0 {
212                                            // Line is already present somewhere in the file, nothing to do
213                                            Remediation::None(String::from(
214                                                "Line already present in the file",
215                                            ))
216                                        } else {
217                                            Remediation::LineInFile(LineInFileApiCall {
218                                                api_call: LineInFileModuleInternalApiCall::Add(
219                                                    LineExpectedPosition::Bottom,
220                                                ),
221                                                line_content: self.line.clone(),
222                                                file_path: self.file_path.clone(),
223                                                privilege,
224                                            })
225                                        }
226                                    }
227                                }
228                            }
229                            None => {
230                                // Line is already present but position is not specified (aka "anywhere"), nothing to do
231                                Remediation::None(format!(
232                                    "Line already present {:?}",
233                                    actual_line_numbers
234                                ))
235                            }
236                        }
237                    }
238                    None => {
239                        // Line is absent and needs to be added
240                        match &parsed_expected_position {
241                            Some(expected_position) => Remediation::LineInFile(LineInFileApiCall {
242                                api_call: LineInFileModuleInternalApiCall::Add(
243                                    expected_position.clone(),
244                                ),
245                                line_content: self.line.clone(),
246                                file_path: self.file_path.clone(),
247                                privilege,
248                            }),
249                            None => {
250                                // Defaults to bottom
251                                Remediation::LineInFile(LineInFileApiCall {
252                                    api_call: LineInFileModuleInternalApiCall::Add(
253                                        LineExpectedPosition::Bottom,
254                                    ),
255                                    line_content: self.line.clone(),
256                                    file_path: self.file_path.clone(),
257                                    privilege,
258                                })
259                            }
260                        }
261                    }
262                }
263            }
264            LineExpectedState::Absent => {
265                // Check if line is already present
266                match is_line_present(
267                    host_handler,
268                    &line_content.clone().unwrap(),
269                    &self.file_path,
270                    &privilege,
271                ) {
272                    Some(line_numbers) => Remediation::LineInFile(LineInFileApiCall {
273                        api_call: LineInFileModuleInternalApiCall::Delete(line_numbers),
274                        line_content: self.line.clone(),
275                        file_path: self.file_path.clone(),
276                        privilege,
277                    }),
278                    None => {
279                        // Line is already absent
280                        Remediation::None(String::from("Line already absent"))
281                    }
282                }
283            }
284        };
285
286        if let Remediation::None(_message) = remediation {
287            Ok(AttributeComplianceAssessment::Compliant)
288        } else {
289            Ok(AttributeComplianceAssessment::NonCompliant(Vec::from([
290                remediation,
291            ])))
292        }
293    }
294}
295
296#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
297pub struct LineInFileApiCall {
298    file_path: String,
299    line_content: Option<Parameter<String>>,
300    pub api_call: LineInFileModuleInternalApiCall,
301    privilege: Privilege,
302}
303
304impl LineInFileApiCall {
305    pub fn display(&self) -> String {
306        match &self.api_call {
307            LineInFileModuleInternalApiCall::Add(line_expected_position) => {
308                return format!(
309                    "Line missing -> needs to be added here {:?}",
310                    line_expected_position
311                );
312            }
313            LineInFileModuleInternalApiCall::Delete(line_numbers) => {
314                return format!("Line present {:?} -> needs to be removed", line_numbers);
315            }
316        }
317    }
318}
319
320impl<Handler: HostHandler> ReachCompliance<Handler> for LineInFileApiCall {
321    async fn call(
322        &self,
323        host_handler: &mut Handler,
324        _host_properties: &Option<HostProperties>,
325        optional_secret_provider: &Option<SecretProvidersPool>,
326    ) -> Result<InternalApiCallOutcome, RegentError> {
327        match &self.api_call {
328            LineInFileModuleInternalApiCall::Add(line_expected_position) => {
329                let filenumberoflines = host_handler
330                    .run_command(
331                        format!("wc -l {} | cut -f 1 -d ' '", self.file_path).as_str(),
332                        &self.privilege,
333                    )
334                    .unwrap()
335                    .stdout
336                    .trim()
337                    .parse::<u64>()
338                    .unwrap();
339
340                // let future_line_number: u64 = match line_expected_position {
341                //     LineExpectedPosition::Top => 1,
342                //     LineExpectedPosition::LineNumber(specific_line_number) => specific_line_number,
343                //     LineExpectedPosition::Bottom | LineExpectedPosition::Anywhere => filelinenumbers
344                // };
345
346                let line_content: Option<String> = match self.line_content.clone() {
347                    Some(parameter) => {
348                        Some(parameter.inner_raw(optional_secret_provider).await.unwrap())
349                    }
350                    None => None,
351                };
352
353                // If the file is empty, the sed command won't work.
354                let cmd: String;
355                if filenumberoflines == 0 {
356                    // File is empty -> matches top|bottom|anywhere|given=1
357                    match line_expected_position {
358                        LineExpectedPosition::Top
359                        | LineExpectedPosition::Bottom
360                        | LineExpectedPosition::Anywhere => {
361                            cmd =
362                                format!("echo \'{}\' >> {}", line_content.unwrap(), self.file_path);
363                        }
364                        LineExpectedPosition::LineNumber(1) => {
365                            cmd =
366                                format!("echo \'{}\' >> {}", line_content.unwrap(), self.file_path);
367                        }
368                        LineExpectedPosition::LineNumber(_any_other_line_number) => {
369                            // Position = <any other value> which is out of range anyway
370                            return Ok(InternalApiCallOutcome::Failure(String::from(
371                                "Position value out of range (use \"bottom\" instead)",
372                            )));
373                        }
374                    }
375                } else {
376                    // File not empty
377                    let future_line_number = match line_expected_position {
378                        LineExpectedPosition::Top => 1,
379                        LineExpectedPosition::Bottom | LineExpectedPosition::Anywhere => {
380                            filenumberoflines
381                        }
382                        LineExpectedPosition::LineNumber(specific_line_number) => {
383                            *specific_line_number
384                        }
385                    };
386                    cmd = format!(
387                        "sed -i \'{} i {}\' {}",
388                        future_line_number,
389                        line_content.unwrap(),
390                        self.file_path
391                    );
392                }
393
394                let cmd_result = host_handler
395                    .run_command(cmd.as_str(), &self.privilege)
396                    .unwrap();
397
398                if cmd_result.return_code == 0 {
399                    return Ok(InternalApiCallOutcome::Success(None));
400                } else {
401                    return Ok(InternalApiCallOutcome::Failure(format!(
402                        "Failed to add line. RC : {}, STDOUT : {}, STDERR : {}",
403                        cmd_result.return_code, cmd_result.stdout, cmd_result.stderr
404                    )));
405                }
406            }
407            LineInFileModuleInternalApiCall::Delete(line_numbers) => {
408                // We need a final command like this : sed -i '7d;12d;16d' input.txt
409                // It implies a little formatting first.
410                let formatted_line_numbers = line_numbers
411                    .clone()
412                    .into_iter()
413                    .map(|i| format!("{}d;", i))
414                    .collect::<String>();
415                let formatted_line_numbers = formatted_line_numbers
416                    .split_at(formatted_line_numbers.len() - 1)
417                    .0; // Delete the last ';
418
419                let cmd = format!("sed -i \'{}\' {}", formatted_line_numbers, self.file_path);
420                let cmd_result = host_handler
421                    .run_command(cmd.as_str(), &self.privilege)
422                    .unwrap();
423
424                if cmd_result.return_code == 0 {
425                    return Ok(InternalApiCallOutcome::Success(None));
426                } else {
427                    return Ok(InternalApiCallOutcome::Failure(format!(
428                        "Failed to remove line. RC : {}, STDOUT : {}, STDERR : {}",
429                        cmd_result.return_code, cmd_result.stdout, cmd_result.stderr
430                    )));
431                }
432            }
433        }
434    }
435}
436
437// Returns a Some(Vec<u64>) representing the line numbers of each occurrence of the line if present, and None if absent
438fn is_line_present<Handler: HostHandler>(
439    host_handler: &mut Handler,
440    line: &String,
441    file_path: &String,
442    privilege: &Privilege,
443) -> Option<Vec<u64>> {
444    let test = host_handler
445        .run_command(
446            format!("grep -n -F -w \'{}\' {}", line, file_path).as_str(), //  Output looks like 4:my line content
447            &privilege,
448        )
449        .unwrap();
450
451    if test.return_code == 0 {
452        let mut line_numbers: Vec<u64> = Vec::new();
453        for line in test.stdout.lines() {
454            line_numbers.push(line.split(':').next().unwrap().parse::<u64>().unwrap());
455        }
456        return Some(line_numbers);
457    } else {
458        return None;
459    }
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465
466    #[test]
467    fn parsing_lineinfile_module_block_from_yaml_str() {
468        let raw_attributes = "---
469
470- FilePath: /path/to/my/file
471  Line: the first line
472  State: !Present
473  Position: !Top
474
475- FilePath: /path/to/my/file
476  Line: 2nd line
477  State: !Present
478  LineNumber: 2
479
480- FilePath: /path/to/my/file
481  Line: the last line
482  State: !Present
483  Position: !Bottom
484
485- FilePath: /path/to/my/file
486  Line: the content expected not to be present at all
487  State: !Absent
488    ";
489
490        let _attributes: Vec<LineInFileBlockExpectedState> =
491            yaml_serde::from_str(raw_attributes).unwrap();
492    }
493}