Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 79 additions & 53 deletions fact-ebpf/src/bpf/d_path.h
Original file line number Diff line number Diff line change
@@ -1,13 +1,74 @@
#pragma once

// clang-format off
#include "maps.h"
#include "vmlinux.h"

#include "maps.h"

#include <bpf/bpf_helpers.h>
#include <bpf/bpf_core_read.h>
// clang-format on

struct d_path_ctx {
struct helper_t* helper;
struct path root;
struct mount* mnt;
struct dentry* dentry;
int offset;
int buflen;
bool success;
};

static long __d_path_inner(uint32_t index, void* _ctx) {
struct d_path_ctx* ctx = (struct d_path_ctx*)_ctx;
struct dentry* dentry = ctx->dentry;
struct dentry* parent = BPF_CORE_READ(dentry, d_parent);
struct mount* mnt = ctx->mnt;
struct dentry* mnt_root = BPF_CORE_READ(mnt, mnt.mnt_root);

if (dentry == mnt_root) {
struct mount* m = BPF_CORE_READ(mnt, mnt_parent);
if (m != mnt) {
ctx->dentry = BPF_CORE_READ(mnt, mnt_mountpoint);
ctx->mnt = m;
return 0;
}
ctx->success = true;
return 1;
}

if (dentry == parent) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do I understand correctly, that this case is not considered to be a "success"? If so, why?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The successful case for d_path is to reach the mount root of the process that is currently running, this condition happens when something goes wrong traversing the mounts and we end up in a position where there are not more parents in the directory cache (usually reaching the root of the device).

I can add some clarifying comments based on the kernel implementation of d_path

return 1;
}

struct qstr d_name;
BPF_CORE_READ_INTO(&d_name, dentry, d_name);
int len = d_name.len & (PATH_MAX - 1);
if (len <= 0 || len >= ctx->buflen) {
return 1;
}

int offset = ctx->offset - len;
if (offset <= 0) {
return 1;
}
offset &= PATH_MAX - 1;

if (bpf_probe_read_kernel(&ctx->helper->buf[offset], len, d_name.name) != 0) {
return 1;
}

offset--;
if (offset <= 0) {
return 1;
}
ctx->helper->buf[offset] = '/';

ctx->offset = offset;
ctx->dentry = parent;
return 0;
}

/**
* Reimplementation of the kernel d_path function.
*
Expand All @@ -19,66 +80,31 @@ __always_inline static long __d_path(const struct path* path, char* buf, int buf
return -1;
}

struct helper_t* helper = get_helper();
if (helper == NULL) {
int offset = (buflen - 1) & (PATH_MAX - 1);
Copy link
Contributor

@erthalion erthalion Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I overlooked this originally, but I think I need some explanation about this part. I assume the intention is to get the offset that fits into PATH_MAX, right? If yes, it seems to work only because PATH_MAX value is 2^n, so that PATH_MAX - 1 will be all ones. But I don't get it how it supposed to work, if buflen is larger than PATH_MAX -- e.g. if I call d_path as:

d_path(&task->mm->exe_file->f_path, p->lineage[i].exe_path, 4097, false);

Then (buflen - 1) & (PATH_MAX - 1) is 1, and the filepath is not copied:

{"timestamp":1766410173613705048,"hostname":"xxxx","process":{"comm":"fish","args":["-fish"],"exe_path":"","container_id":null,"uid":4206644,"username":"","gid":4206644,"login_uid":4206644,"pid":70608,"in_root_mount_ns":true,"lineage":[{"uid":4206644,"exe_path":""},{"uid":0,"exe_path":""}]},"file":{"Open":{"filename":"","host_file":"/file/path/test","inode":{"inode":55138507,"dev":40}}}}

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this is mostly to keep the verifier happy. The max length of a path in Linux is defined to be 4096 bytes, which when substracting 1 from it ends up with a binary number that is all 1s as you pointed out, so doing this bitwise and is just a convenient way to have the verifier not complain about offset being unbound.

The entire logic behind this implementation relies on PATH_MAX being 4K and the buffer length provided never being greater than PATH_MAX, these are invariants and if broken, then behavior will be undefined.

I should probably do something about better documenting these invariants, at the moment they are just sitting there.

https://github.com/torvalds/linux/blob/9448598b22c50c8a5bb77a9103e2d49f134c9578/include/uapi/linux/limits.h#L13

struct d_path_ctx ctx = {
.buflen = buflen,
.helper = get_helper(),
.offset = offset,
};

if (ctx.helper == NULL) {
return -1;
}

struct task_struct* task = (struct task_struct*)bpf_get_current_task();
int offset = (buflen - 1) & (PATH_MAX - 1);
helper->buf[offset] = '\0'; // Ensure null termination

struct path root;
BPF_CORE_READ_INTO(&root, task, fs, root);
struct mount* mnt = container_of(path->mnt, struct mount, mnt);
struct dentry* dentry;
BPF_CORE_READ_INTO(&dentry, path, dentry);

for (int i = 0; i < 16 && (dentry != root.dentry || &mnt->mnt != root.mnt); i++) {
struct dentry* parent = BPF_CORE_READ(dentry, d_parent);
struct dentry* mnt_root = BPF_CORE_READ(mnt, mnt.mnt_root);

if (dentry == mnt_root) {
struct mount* m = BPF_CORE_READ(mnt, mnt_parent);
if (m != mnt) {
dentry = BPF_CORE_READ(mnt, mnt_mountpoint);
mnt = m;
continue;
}
break;
}

if (dentry == parent) {
return -1;
}
ctx.helper->buf[offset] = '\0'; // Ensure null termination

struct qstr d_name;
BPF_CORE_READ_INTO(&d_name, dentry, d_name);
int len = d_name.len;
if (len <= 0 || len >= buflen) {
return -1;
}
BPF_CORE_READ_INTO(&ctx.root, task, fs, root);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How much if BPF_CORE_READ/BPF_CORE_READ_INFO is actually needed in this patch? I've tried to use bpf_get_current_task_btf, other structs seems to be already BTF enhanced -- and at least on Fedora simple access without the macro works fine.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can try it out but I can already think of a couple reason why this might not work:

  • A lot of the logic in this d_path implementation relies on the container_of cast to struct mount, I'm fairly certain that call loses all BTF information, but I need to test it.
  • Going through bpf_loop means we use an intermediate struct that needs casting, fairly certain that also messes with the BTF information of types.

ctx.mnt = container_of(path->mnt, struct mount, mnt);
BPF_CORE_READ_INTO(&ctx.dentry, path, dentry);

offset -= len;
if (offset <= 0) {
return -1;
}

if (bpf_probe_read_kernel(&helper->buf[offset], len, d_name.name) != 0) {
return -1;
}

offset--;
if (offset <= 0) {
return -1;
}
helper->buf[offset] = '/';

dentry = parent;
long res = bpf_loop(PATH_MAX, __d_path_inner, &ctx, 0);
if (res <= 0 || !ctx.success) {
return -1;
}

bpf_probe_read_str(buf, buflen, &helper->buf[offset]);
return buflen - offset;
bpf_probe_read_str(buf, buflen, &ctx.helper->buf[ctx.offset & (PATH_MAX - 1)]);
return buflen - ctx.offset;
}

__always_inline static long d_path(struct path* path, char* buf, int buflen, bool use_bpf_helper) {
Expand Down
81 changes: 43 additions & 38 deletions fact/src/bpf/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,6 @@ impl Bpf {
mod bpf_tests {
use std::{env, path::PathBuf, time::Duration};

use anyhow::Context;
use fact_ebpf::file_activity_type_t;
use tempfile::NamedTempFile;
use tokio::{sync::watch, time::timeout};
Expand All @@ -241,72 +240,78 @@ mod bpf_tests {

use super::*;

fn get_executor() -> anyhow::Result<tokio::runtime::Runtime> {
let executor = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.context("Failed building tokio runtime")?;
Ok(executor)
}

#[test]
fn test_basic() {
#[tokio::test]
async fn test_basic() {
if let Ok(value) = std::env::var("FACT_LOGLEVEL") {
let value = value.to_lowercase();
if value == "debug" || value == "trace" {
crate::init_log().unwrap();
}
}

let executor = get_executor().unwrap();
let monitored_path = env!("CARGO_MANIFEST_DIR");
let monitored_path = PathBuf::from(monitored_path);
let paths = vec![monitored_path.clone()];
let mut config = FactConfig::default();
config.set_paths(paths);
let reloader = Reloader::from(config);
executor.block_on(async {
let (tx, mut rx) = mpsc::channel(100);
let mut bpf = Bpf::new(reloader.paths(), reloader.config().ringbuf_size(), tx)
.expect("Failed to load BPF code");
let (run_tx, run_rx) = watch::channel(true);
// Create a metrics exporter, but don't start it
let exporter = Exporter::new(bpf.take_metrics().unwrap());
let (tx, mut rx) = mpsc::channel(100);
let mut bpf = Bpf::new(reloader.paths(), reloader.config().ringbuf_size(), tx)
.expect("Failed to load BPF code");
let (run_tx, run_rx) = watch::channel(true);
// Create a metrics exporter, but don't start it
let exporter = Exporter::new(bpf.take_metrics().unwrap());

let handle = bpf.start(run_rx, exporter.metrics.bpf_worker.clone());

let handle = bpf.start(run_rx, exporter.metrics.bpf_worker.clone());
tokio::time::sleep(Duration::from_millis(500)).await;

tokio::time::sleep(Duration::from_millis(500)).await;
// Create a file
let file = NamedTempFile::new_in(monitored_path).expect("Failed to create temporary file");
println!("Created {file:?}");

// Create a file
let file =
NamedTempFile::new_in(monitored_path).expect("Failed to create temporary file");
println!("Created {file:?}");
let current = Process::current();
let file_path = file.path().to_path_buf();

let expected = Event::new(
let expected_events = [
Event::new(
file_activity_type_t::FILE_ACTIVITY_CREATION,
host_info::get_hostname(),
file.path().to_path_buf(),
file_path.clone(),
PathBuf::new(), // host path is resolved by HostScanner
current.clone(),
)
.unwrap(),
Event::new(
file_activity_type_t::FILE_ACTIVITY_UNLINK,
host_info::get_hostname(),
file_path,
PathBuf::new(), // host path is resolved by HostScanner
Process::current(),
current,
)
.unwrap();
.unwrap(),
];

println!("Expected: {expected:?}");
let wait = timeout(Duration::from_secs(1), async move {
// Close the file, removing it
file.close().expect("Failed to close temp file");

println!("Expected: {expected_events:?}");
let wait = timeout(Duration::from_secs(1), async move {
for expected in expected_events {
while let Some(event) = rx.recv().await {
println!("{event:?}");
if event == expected {
break;
}
}
});

tokio::select! {
res = wait => res.unwrap(),
res = handle => res.unwrap().unwrap(),
}

run_tx.send(false).unwrap();
});

tokio::select! {
res = wait => res.unwrap(),
res = handle => res.unwrap().unwrap(),
}

run_tx.send(false).unwrap();
}
}
Loading