1use std::{
4 path::{Path, PathBuf},
5 process::Command,
6};
7
8use clingwrap::runner::{CommandError, CommandRunner};
9
10use crate::{
11 action::{PostPlanAction, PrePlanAction, UnsafeAction},
12 action_impl::bash_snippet,
13 config::Config,
14 project::{Project, Projects},
15};
16
17#[derive(Debug)]
19pub struct Linter<'a> {
20 config: &'a Config,
21 projects: &'a Projects,
22 lints: Vec<Box<dyn Lint>>,
23}
24
25impl<'a> Linter<'a> {
26 pub fn new(config: &'a Config, projects: &'a Projects) -> Self {
28 Self {
29 config,
30 projects,
31 lints: vec![
32 Box::new(RsyncTarget),
33 Box::new(HttpGet),
34 Box::new(ShellCheck),
35 Box::new(DebDputVersions),
36 Box::new(OldWorkspacePath),
37 ],
38 }
39 }
40
41 pub fn lint(&self) -> Result<(), LinterError> {
43 for (name, project) in self.projects.iter() {
44 for lint in self.lints.iter() {
45 lint.check(self.config, name, project)?;
46 }
47 }
48 Ok(())
49 }
50}
51
52trait Lint: std::fmt::Debug {
53 #[allow(dead_code)]
54 fn name(&self) -> &'static str;
55 fn check(
56 &self,
57 config: &Config,
58 project_name: &str,
59 project: &Project,
60 ) -> Result<(), LinterError>;
61}
62
63#[derive(Debug)]
64struct RsyncTarget;
65
66impl Lint for RsyncTarget {
67 fn name(&self) -> &'static str {
68 "rsync_target"
69 }
70
71 fn check(
72 &self,
73 config: &Config,
74 project_name: &str,
75 project: &Project,
76 ) -> Result<(), LinterError> {
77 let uses_rsync = project
78 .post_plan()
79 .iter()
80 .any(|a| matches!(a, PostPlanAction::Rsync | PostPlanAction::Rsync2));
81 let configs_rsync = config.rsync_target_for_project(project_name).is_some();
82 if uses_rsync && !configs_rsync {
83 return Err(LinterError::rsync_target_missing(project_name));
84 }
85 Ok(())
86 }
87}
88
89#[derive(Debug)]
90struct HttpGet;
91
92impl Lint for HttpGet {
93 fn name(&self) -> &'static str {
94 "http_get"
95 }
96
97 fn check(
98 &self,
99 _config: &Config,
100 project_name: &str,
101 project: &Project,
102 ) -> Result<(), LinterError> {
103 for action in project.pre_plan() {
104 let mut filenames = vec![];
105 if let PrePlanAction::HttpGet { items } = action {
106 for item in items {
107 if filenames.contains(&item.filename) {
108 return Err(LinterError::http_get_duplicate_filename(
109 project_name,
110 &item.filename,
111 ));
112 }
113 filenames.push(item.filename.clone());
114 }
115 }
116 }
117
118 Ok(())
119 }
120}
121
122#[derive(Debug)]
123struct ShellCheck;
124
125impl Lint for ShellCheck {
126 fn name(&self) -> &'static str {
127 "shellcheck"
128 }
129
130 fn check(
131 &self,
132 _config: &Config,
133 _project_name: &str,
134 project: &Project,
135 ) -> Result<(), LinterError> {
136 for action in project.plan() {
137 if let UnsafeAction::Shell { shell } = action {
138 let mut cmd = Command::new("shellcheck");
139 cmd.args(["-s", "bash", "-"]);
140 let mut runner = CommandRunner::new(cmd);
141 runner.feed_stdin(bash_snippet(shell).as_bytes());
142 runner.capture_stdout();
143 runner.capture_stderr();
144 match runner.execute() {
145 Ok(_) => (),
146 Err(CommandError::CommandFailed { output, .. }) => {
147 return Err(LinterError::shellcheck(output.stdout))
148 }
149 Err(err) => return Err(LinterError::ShellcheckError(err)),
150 }
151 }
152 }
153
154 Ok(())
155 }
156}
157
158#[derive(Debug)]
159struct DebDputVersions;
160
161impl Lint for DebDputVersions {
162 fn name(&self) -> &'static str {
163 "deb_dput_versions"
164 }
165
166 fn check(
167 &self,
168 _config: &Config,
169 _project_name: &str,
170 project: &Project,
171 ) -> Result<(), LinterError> {
172 let has_deb = project
173 .plan()
174 .iter()
175 .any(|action| matches!(action, UnsafeAction::Deb));
176 let has_deb2 = project
177 .plan()
178 .iter()
179 .any(|action| matches!(action, UnsafeAction::Deb2));
180 let has_dput = project
181 .post_plan()
182 .iter()
183 .any(|action| matches!(action, PostPlanAction::Dput));
184 let has_dput2 = project
185 .post_plan()
186 .iter()
187 .any(|action| matches!(action, PostPlanAction::Dput2));
188 if (has_deb && has_dput2) || (has_deb2 && has_dput) {
189 Err(LinterError::IncompatibleDebianActions)
190 } else {
191 Ok(())
192 }
193 }
194}
195
196#[derive(Debug)]
197struct OldWorkspacePath;
198
199impl Lint for OldWorkspacePath {
200 fn name(&self) -> &'static str {
201 "/workspace_used"
202 }
203
204 fn check(
205 &self,
206 _config: &Config,
207 _project_name: &str,
208 project: &Project,
209 ) -> Result<(), LinterError> {
210 let old_path_used = project.plan().iter().any(|action| {
211 if let UnsafeAction::Shell { shell } = action {
212 shell.contains("/workspace")
213 } else {
214 false
215 }
216 });
217 if old_path_used {
218 Err(LinterError::OldWorkspacePath)
219 } else {
220 Ok(())
221 }
222 }
223}
224
225#[derive(Debug, thiserror::Error)]
227pub enum LinterError {
228 #[error(
230 "rsync or rsync2 action used in project {project}, but no `rsync` target in configuration"
231 )]
232 RsyncTargetMissing {
233 project: String,
235 },
236
237 #[error("in project {project_name} http_get pre-plan action uses the same filename for more than one item: {filename}")]
239 HttpGetDuplicateFilename {
240 project_name: String,
242
243 filename: PathBuf,
245 },
246
247 #[error("shellcheck found problems: {0}")]
249 Shellcheck(String),
250
251 #[error("failed to run shellcheck")]
253 ShellcheckError(#[source] CommandError),
254
255 #[error("has incompible deb/deb2 and dput/dput2 pairs, use only one version")]
257 IncompatibleDebianActions,
258
259 #[error("old path /workspace used, use /ci instead")]
261 OldWorkspacePath,
262}
263
264impl LinterError {
265 fn rsync_target_missing(project_name: &str) -> Self {
266 Self::RsyncTargetMissing {
267 project: project_name.into(),
268 }
269 }
270
271 fn http_get_duplicate_filename(project_name: &str, filename: &Path) -> Self {
272 Self::HttpGetDuplicateFilename {
273 project_name: project_name.into(),
274 filename: filename.into(),
275 }
276 }
277
278 fn shellcheck(output: Vec<u8>) -> Self {
279 let output = String::from_utf8_lossy(&output).to_string();
280 Self::Shellcheck(output)
281 }
282}