1use std::path::{Path, PathBuf};
4
5use anyhow::Context;
6use qk_input_analysis::Access;
7use qk_input_analysis::strace::Limits;
8
9#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11pub enum Choice {
12 Auto,
14 Ebpf,
15 Strace,
16}
17
18impl Choice {
19 pub fn from_env() -> anyhow::Result<Self> {
20 Self::set(std::env::var_os("QK_RECORDER").as_deref())
21 }
22
23 fn set(value: Option<&std::ffi::OsStr>) -> anyhow::Result<Self> {
25 match value.map(std::ffi::OsStr::to_str) {
26 None => Ok(Self::Auto),
27 Some(Some(name)) => Self::named(name),
28 Some(None) => anyhow::bail!(
29 "unknown QK_RECORDER {value:?}, which is not UTF-8; use auto, ebpf or strace",
30 value = value.unwrap_or_default()
31 ),
32 }
33 }
34
35 fn named(name: &str) -> anyhow::Result<Self> {
36 match name {
37 "" | "auto" => Ok(Self::Auto),
38 "ebpf" => Ok(Self::Ebpf),
39 "strace" => Ok(Self::Strace),
40 other => anyhow::bail!("unknown QK_RECORDER {other:?}; use auto, ebpf or strace"),
41 }
42 }
43}
44
45#[derive(Debug)]
47pub enum Probed<R = Recording> {
48 Ready(R),
49 Unsupported,
51 Unavailable(anyhow::Error),
54}
55
56fn decide<R>(
60 choice: Choice,
61 linux: bool,
62 ebpf: impl FnOnce() -> Result<R, String>,
63 strace: impl FnOnce() -> anyhow::Result<R>,
64) -> anyhow::Result<Probed<R>> {
65 if !linux {
66 return match choice {
67 Choice::Auto => Ok(Probed::Unsupported),
68 Choice::Ebpf => anyhow::bail!(
69 "recording with eBPF needs Linux; unset QK_RECORDER or set it to auto"
70 ),
71 Choice::Strace => anyhow::bail!(
72 "recording with strace needs Linux; unset QK_RECORDER or set it to auto"
73 ),
74 };
75 }
76 match choice {
77 Choice::Ebpf => match ebpf() {
78 Ok(recording) => Ok(Probed::Ready(recording)),
79 Err(error) => anyhow::bail!("cannot record with eBPF: {error}"),
80 },
81 Choice::Strace => strace().map(Probed::Ready),
82 Choice::Auto => match ebpf() {
83 Ok(recording) => Ok(Probed::Ready(recording)),
84 Err(_) => Ok(match strace() {
85 Ok(recording) => Probed::Ready(recording),
86 Err(error) => Probed::Unavailable(error),
87 }),
88 },
89 }
90}
91
92#[derive(Clone, Debug)]
94pub struct Recording {
95 pub program: PathBuf,
98 pub directory: PathBuf,
99 pub seccomp: bool,
102 keys: bool,
105 #[cfg(target_os = "linux")]
108 ebpf: Option<Ebpf>,
109}
110
111#[cfg(target_os = "linux")]
112#[derive(Clone, Debug)]
113struct Ebpf {
114 recorder: std::sync::Arc<qk_trace::Recorder>,
115 sessions: std::sync::Arc<std::sync::Mutex<Vec<qk_trace::Session>>>,
116 failure: std::sync::Arc<std::sync::Mutex<Option<String>>>,
119 resolve: bool,
122}
123
124#[cfg(target_os = "linux")]
128fn ebpf_recorder() -> Result<std::sync::Arc<qk_trace::Recorder>, String> {
129 static RECORDER: std::sync::OnceLock<Result<std::sync::Arc<qk_trace::Recorder>, String>> =
130 std::sync::OnceLock::new();
131 RECORDER
132 .get_or_init(|| {
133 let qk = std::env::current_exe().map_err(|error| error.to_string())?;
134 let helper = if unsafe { libc::geteuid() } == 0 {
136 let mut helper = std::process::Command::new(qk);
137 helper.arg("__trace-helper");
138 helper
139 } else {
140 let mut helper = std::process::Command::new("sudo");
141 helper.args(["-n", "--"]).arg(qk).arg("__trace-helper");
142 helper
143 };
144 qk_trace::Recorder::start(helper).map_err(|error| format!("{error:#}"))
145 })
146 .clone()
147}
148
149impl Recording {
150 pub fn probe(directory: &Path) -> anyhow::Result<Probed> {
156 decide(
157 Choice::from_env()?,
158 cfg!(target_os = "linux"),
159 || Self::ebpf(directory),
160 || Self::strace(directory),
161 )
162 }
163
164 pub fn required(directory: &Path) -> anyhow::Result<Self> {
167 match Self::probe(directory)? {
168 Probed::Ready(recording) => Ok(recording),
169 Probed::Unavailable(error) => Err(error),
170 Probed::Unsupported => anyhow::bail!("recording needs Linux"),
171 }
172 }
173
174 #[cfg(target_os = "linux")]
175 fn ebpf(directory: &Path) -> Result<Self, String> {
176 let recorder = ebpf_recorder()?;
177 Ok(Self {
178 program: std::env::current_exe().map_err(|error| error.to_string())?,
179 directory: directory.to_owned(),
180 seccomp: false,
181 keys: false,
182 ebpf: Some(Ebpf {
183 recorder,
184 sessions: Default::default(),
185 failure: Default::default(),
186 resolve: false,
187 }),
188 })
189 }
190
191 #[cfg(not(target_os = "linux"))]
192 fn ebpf(_: &Path) -> Result<Self, String> {
193 Err("the eBPF recorder records only on Linux".into())
194 }
195
196 fn strace(directory: &Path) -> anyhow::Result<Self> {
197 let path = std::env::var_os("PATH").context("recording needs strace on PATH")?;
198 let program = std::env::split_paths(&path)
199 .map(|directory| directory.join("strace"))
200 .find(|candidate| candidate.is_file())
201 .context("recording needs strace on PATH; install strace and retry")?
202 .canonicalize()?;
203 let mut failure = String::new();
204 for seccomp in [true, false] {
205 let recording = Self {
206 program: program.clone(),
207 directory: directory.to_owned(),
208 seccomp,
209 keys: false,
210 #[cfg(target_os = "linux")]
211 ebpf: None,
212 };
213 let probe = recording
214 .command(0, "exec /bin/true", Path::new("/"))
215 .output()
216 .context("cannot probe strace")?;
217 if probe.status.success() {
218 return Ok(recording);
219 }
220 failure = String::from_utf8_lossy(&probe.stderr).into_owned();
221 }
222 anyhow::bail!("strace cannot record child processes: {failure}")
223 }
224
225 pub fn backend(&self) -> &'static str {
227 #[cfg(target_os = "linux")]
228 if self.ebpf.is_some() {
229 return "ebpf";
230 }
231 "strace"
232 }
233
234 pub fn in_directory(&self, directory: PathBuf) -> Self {
236 Self {
237 directory,
238 #[cfg(target_os = "linux")]
239 ebpf: self.ebpf.as_ref().map(|ebpf| Ebpf {
240 recorder: ebpf.recorder.clone(),
241 sessions: Default::default(),
242 failure: Default::default(),
243 resolve: ebpf.resolve,
244 }),
245 ..self.clone()
246 }
247 }
248
249 pub fn resolving(self) -> Self {
252 #[cfg(target_os = "linux")]
253 {
254 let mut recording = self;
255 if let Some(ebpf) = &mut recording.ebpf {
256 ebpf.resolve = true;
257 }
258 recording
259 }
260 #[cfg(not(target_os = "linux"))]
261 self
262 }
263
264 pub fn keying(self) -> Self {
266 Self { keys: true, ..self }
267 }
268
269 pub fn keys(&self) -> bool {
272 self.keys
273 }
274
275 pub fn settle(&self) -> anyhow::Result<()> {
279 #[cfg(target_os = "linux")]
280 if let Some(ebpf) = &self.ebpf {
281 if let Some(failure) = ebpf.failure.lock().unwrap().as_ref() {
282 anyhow::bail!("{failure}");
283 }
284 let sessions = std::mem::take(&mut *ebpf.sessions.lock().unwrap());
285 let mut sessions = sessions.into_iter();
286 while let Some(session) = sessions.next() {
287 if let Err(error) = ebpf.recorder.finish(session) {
288 *ebpf.failure.lock().unwrap() = Some(format!("{error:#}"));
289 sessions
292 .by_ref()
293 .for_each(|rest| ebpf.recorder.abandon(rest));
294 return Err(error);
295 }
296 }
297 }
298 Ok(())
299 }
300
301 pub fn accesses(
305 &self,
306 cwd: &Path,
307 limits: &Limits,
308 ) -> anyhow::Result<(Vec<Access>, Vec<String>)> {
309 self.settle()?;
310 qk_input_analysis::strace::collect_within(&self.directory, cwd, limits)
311 }
312
313 pub fn whole_accesses(&self, cwd: &Path, limits: &Limits) -> Result<Vec<Access>, String> {
316 let (accesses, issues) = self
317 .accesses(cwd, limits)
318 .map_err(|error| format!("{error:#}"))?;
319 match issues.into_iter().next() {
320 Some(issue) => Err(issue),
321 None => Ok(accesses),
322 }
323 }
324
325 pub fn command(&self, index: usize, text: &str, cwd: &Path) -> std::process::Command {
328 #[cfg(target_os = "linux")]
329 if let Some(ebpf) = &self.ebpf {
330 use std::os::unix::process::CommandExt;
331 let session = ebpf.recorder.begin(
332 &self.directory,
333 &format!("command-{index}"),
334 cwd,
335 ebpf.resolve,
336 );
337 ebpf.sessions.lock().unwrap().push(session);
338 let mut command = crate::process::shell(text);
339 unsafe { command.pre_exec(ebpf.recorder.join(session)) };
341 return command;
342 }
343 let _ = cwd;
344 let mut command = std::process::Command::new(&self.program);
345 if self.seccomp {
347 command.arg("--seccomp-bpf");
348 }
349 command
350 .args([
351 "-ff",
352 "-ttt",
353 "-yy",
354 "-qq",
355 "-s",
356 "4096",
357 "-e",
358 "trace=%file,%process,getdents64,fchdir,close,dup,dup2,dup3,getcwd",
359 "-e",
360 "raw=execve,execveat",
361 "-o",
362 ])
363 .arg(self.directory.join(format!("command-{index}")))
364 .args(["--", "/bin/sh", "-c", text]);
365 command
366 }
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372
373 fn decided(
374 choice: Choice,
375 ebpf: Result<&'static str, &'static str>,
376 strace: Result<&'static str, &'static str>,
377 ) -> Result<Probed<&'static str>, String> {
378 decide(
379 choice,
380 true,
381 || ebpf.map_err(str::to_owned),
382 || strace.map_err(|error| anyhow::anyhow!(error)),
383 )
384 .map_err(|error| format!("{error:#}"))
385 }
386
387 fn ready(probed: Result<Probed<&'static str>, String>) -> Option<&'static str> {
388 match probed {
389 Ok(Probed::Ready(recording)) => Some(recording),
390 _ => None,
391 }
392 }
393
394 #[test]
395 fn auto_takes_ebpf_then_strace_and_goes_on_without_either() {
396 assert_eq!(
397 ready(decided(Choice::Auto, Ok("ebpf"), Ok("strace"))),
398 Some("ebpf")
399 );
400 assert_eq!(
401 ready(decided(Choice::Auto, Err("no"), Ok("strace"))),
402 Some("strace")
403 );
404 match decided(Choice::Auto, Err("no eBPF"), Err("no strace")) {
405 Ok(Probed::Unavailable(error)) => assert_eq!(error.to_string(), "no strace"),
406 other => panic!("{other:?}"),
407 }
408 }
409
410 #[test]
411 fn a_named_recorder_records_or_fails() {
412 assert_eq!(
413 ready(decided(Choice::Ebpf, Ok("ebpf"), Ok("strace"))),
414 Some("ebpf")
415 );
416 assert_eq!(
417 decided(Choice::Ebpf, Err("no helper"), Ok("strace")).unwrap_err(),
418 "cannot record with eBPF: no helper"
419 );
420 assert_eq!(
421 ready(decided(Choice::Strace, Ok("ebpf"), Ok("strace"))),
422 Some("strace")
423 );
424 assert_eq!(
425 decided(Choice::Strace, Ok("ebpf"), Err("no strace")).unwrap_err(),
426 "no strace"
427 );
428 }
429
430 #[test]
431 fn strace_named_is_never_ebpf() {
432 let probed = decide(
433 Choice::Strace,
434 true,
435 || -> Result<&str, String> { panic!("eBPF probed") },
436 || Ok("strace"),
437 );
438 assert_eq!(
439 ready(probed.map_err(|error| error.to_string())),
440 Some("strace")
441 );
442 }
443
444 #[test]
445 fn off_linux_auto_records_nothing_and_a_recorder_named_fails() {
446 let off = |choice| {
447 decide(
448 choice,
449 false,
450 || -> Result<&str, String> { panic!("eBPF probed") },
451 || panic!("strace probed"),
452 )
453 };
454 assert!(matches!(off(Choice::Auto), Ok(Probed::Unsupported)));
455 assert_eq!(
456 off(Choice::Ebpf).unwrap_err().to_string(),
457 "recording with eBPF needs Linux; unset QK_RECORDER or set it to auto"
458 );
459 assert_eq!(
460 off(Choice::Strace).unwrap_err().to_string(),
461 "recording with strace needs Linux; unset QK_RECORDER or set it to auto"
462 );
463 }
464
465 #[cfg(unix)]
466 #[test]
467 fn qk_recorder_unset_is_auto_and_not_utf8_fails() {
468 use std::os::unix::ffi::OsStrExt;
469 assert_eq!(Choice::set(None).unwrap(), Choice::Auto);
470 assert_eq!(
471 Choice::set(Some("strace".as_ref())).unwrap(),
472 Choice::Strace
473 );
474 let error = Choice::set(Some(std::ffi::OsStr::from_bytes(b"str\xffce")))
475 .unwrap_err()
476 .to_string();
477 assert!(error.starts_with("unknown QK_RECORDER "), "{error}");
478 assert!(
479 error.ends_with(", which is not UTF-8; use auto, ebpf or strace"),
480 "{error}"
481 );
482 }
483
484 #[test]
485 fn qk_recorder_names_one_of_three() {
486 assert_eq!(Choice::named("").unwrap(), Choice::Auto);
487 assert_eq!(Choice::named("auto").unwrap(), Choice::Auto);
488 assert_eq!(Choice::named("ebpf").unwrap(), Choice::Ebpf);
489 assert_eq!(Choice::named("strace").unwrap(), Choice::Strace);
490 assert_eq!(
491 Choice::named("strase").unwrap_err().to_string(),
492 "unknown QK_RECORDER \"strase\"; use auto, ebpf or strace"
493 );
494 }
495}