Modern Linux systems provide a fascinating feature for overriding shared library behavior at runtime: LD_PRELOAD.
This environment variable lets you inject a custom shared library before anything else is loaded — meaning you can
intercept and modify calls to common functions like open, read, connect, and more.
In this post, we’ll walk through hooking the open() function using LD_PRELOAD and a simple shared object. No extra
tooling required — just a few lines of C, and the ability to compile a .so file.
Intercepting open()
Let’s write a tiny library that intercepts calls to open() and prints the file path being accessed. We’ll also
forward the call to the real open() so the program behaves normally.
Create a file named hook_open.c with the following:
#define _GNU_SOURCE
#include<stdio.h>
#include<stdarg.h>
#include<dlfcn.h>
#include<fcntl.h>intopen(constchar*pathname,intflags,...){staticint(*real_open)(constchar*,int,...)=NULL;if(!real_open)real_open=dlsym(RTLD_NEXT,"open");va_listargs;va_start(args,flags);mode_tmode=va_arg(args,int);va_end(args);fprintf(stderr,"[HOOK] open() called with path: %s\n",pathname);returnreal_open(pathname,flags,mode);}
This function matches the signature of open, grabs the “real” function using dlsym(RTLD_NEXT, ...), and then
forwards the call after logging it.
Note We use va_list to handle the optional mode argument safely.
Compiling the Hook
Compile your code into a shared object:
gcc -fPIC-shared-o hook_open.so hook_open.c -ldl
Now you can use this library with any dynamically linked program that calls open.
Testing with a Simple Program
Try running a standard tool like cat to confirm that it’s using open():
LD_PRELOAD=./hook_open.so cat hook_open.c
You should see:
[HOOK] open() called with path: hook_open.c
#define _GNU_SOURCE
...
Each time the program calls open(), your hook intercepts it, logs the call, and passes control along.
Notes and Gotchas
This only works with dynamically linked binaries — statically linked programs don’t go through the dynamic linker.
Some programs (like ls) may use openat() instead of open(). You can hook that too, using the same method.
If your hook causes a crash or hangs, it’s often due to incorrect use of va_arg or missing dlsym resolution.
Where to Go From Here
You can expand this basic example to:
Block access to specific files
Redirect file paths
Inject fake contents
Hook other syscalls like connect(), write(), execve()
LD_PRELOAD is a powerful mechanism for debugging, sandboxing, and learning how programs interact with the system.
Just don’t forget — you’re rewriting the behavior of fundamental APIs at runtime.
Hexagonal Architecture, also known as Ports and
Adapters, is a compelling design pattern that encourages the decoupling of domain logic from infrastructure concerns.
In this post, I’ll walk through a Rust project called banker that adopts this architecture, showing how it helps
keep domain logic clean, composable, and well-tested.
You can follow along with the full code up in my GitHub Repository to get this
running locally.
Project Structure
The banker project is organized as a set of crates:
crates/
├── banker-core # The domain and business logic
├── banker-adapters # Infrastructure adapters (e.g. in-memory repo)
├── banker-fixtures # Helpers and test data
└── banker-http # Web interface via Axum
Each crate plays a role in isolating logic boundaries:
banker-core defines the domain entities, business rules, and traits (ports).
banker-adapters implements the ports with concrete infrastructure (like an in-memory repository).
banker-fixtures provides test helpers and mock repositories.
banker-http exposes an HTTP API with axum, calling into the domain via ports.
Structurally, the project flows as follows:
graph TD
subgraph Core
BankService
AccountRepo[AccountRepo trait]
end
subgraph Adapters
HTTP[HTTP Handler]
InMemory[InMemoryAccountRepo]
Fixtures[Fixture Test Repo]
end
HTTP -->|calls| BankService
BankService -->|trait| AccountRepo
InMemory -->|implements| AccountRepo
Fixtures -->|implements| AccountRepo
Defining the Domain (banker-core)
In Hexagonal Architecture, the domain represents the core of your application—the rules, behaviors, and models that
define what your system actually does. It’s intentionally isolated from infrastructure concerns like databases or HTTP.
This separation ensures the business logic remains testable, reusable, and resilient to changes in external technology
choices.
The banker-core crate contains the central business model:
pubstructBank<R:AccountRepo>{repo:R,}impl<R:AccountRepo>Bank<R>{pubfndeposit(&self,cmd:Deposit)->Result<Account,BankError>{letmutacct=self.repo.get(&cmd.id)?.ok_or(BankError::NotFound)?;acct.balance_cents+=cmd.amount_cents;self.repo.upsert(&acct)?;Ok(acct)}// ... open and withdraw omitted for brevity}
The Bank struct acts as the use-case layer, coordinating logic between domain entities and ports.
Implementing Adapters
In Hexagonal Architecture, adapters are the glue between your domain and the outside world. They translate external
inputs (like HTTP requests or database queries) into something your domain understands—and vice versa. Adapters
implement the domain’s ports (traits), allowing your application core to remain oblivious to how and where the data
comes from.
The in-memory repository implements the AccountRepo trait and lives in banker-adapters:
The outermost layer of a hexagonal architecture typically handles transport—the mechanism through which external actors
interact with the system. In our case, that’s HTTP, implemented using the axum framework. This layer invokes domain
services via the ports defined in banker-core, ensuring the business logic remains insulated from the specifics of web
handling.
In banker-http, we wire up the application for HTTP access using axum:
Each handler invokes domain logic through the Bank service, returning simple JSON responses.
This is one example of a primary adapter—other adapters (e.g., CLI, gRPC) could be swapped in without changing the core.
Takeaways
Traits in Rust are a perfect match for defining ports.
Structs implementing those traits become adapters—testable and swappable.
The core domain crate (banker-core) has no dependencies on infrastructure or axum.
Tests can exercise the domain logic via fixtures and in-memory mocks.
Hexagonal Architecture in Rust isn’t just theoretical—it’s ergonomic. With traits, lifetimes, and ownership semantics,
you can cleanly separate concerns while still writing expressive, high-performance code.
One of the most powerful ideas behind deep learning is backpropagation—the algorithm that lets a neural network learn
from its mistakes. But while modern tools like PyTorch and TensorFlow
make it easy to use backprop, they also hide the magic.
In this post, we’ll strip things down to the fundamentals and implement a neural network from scratch in NumPy
to solve the XOR problem.
Along the way, we’ll dig into what backprop really is, how it works, and why it matters.
What Is Backpropagation?
Backpropagation is a method for computing how to adjust the weights—the tunable parameters of a neural network—so
that it improves its predictions. It does this by minimizing a loss function, which measures how far off the
network’s outputs are from the correct answers. To do that, it calculates gradients, which tell us how much each
weight contributes to the overall error and how to adjust it to reduce that error.
Think of it like this:
In calculus, we use derivatives to understand how one variable changes with respect to another.
In neural networks, we want to know: How much does this weight affect the final error?
Enter the chain rule—a calculus technique that lets us break down complex derivatives into manageable parts.
Backpropagation applies the chain rule across all the layers in a network, allowing us to efficiently compute the
gradient of the loss function for every weight.
Neural Network Flow
graph TD
A[Input Layer] --> B[Hidden Layer]
B --> C[Output Layer]
C --> D[Loss Function]
D -->|Backpropagate| C
C -->|Backpropagate| B
B -->|Backpropagate| A
We push inputs forward through the network to get predictions (forward pass), then pull error gradients backward to
adjust the weights (backward pass).
Solving XOR with a Neural Network
The XOR problem is a classic test for neural networks. It looks like this:
Input
Output
[0, 0]
0
[0, 1]
1
[1, 0]
1
[1, 1]
0
A simple linear model can’t solve XOR because it’s not linearly separable. But with a small neural network—just one
hidden layer—we can crack it.
We’ll walk through our implementation step by step.
The x matrix defines all of our inputs. You can see these as the bit pairs that you’d normally pass through an xor
operation. The y matrix then defines the “well known” outputs.
The input_size is the number of input features. We have two values going in as an input here.
The hidden_size is the number of “neurons” in the hidden layer. Hidden layers are where the network transforms
input into internal features. XOR requires non-linear transformation, so at least one hidden layer is essential. Setting
this to 2 keeps the network small, but expressive enough to learn XOR.
output_size is the number of output neurons. XOR is a binary classification problem so we only need a single output.
Finally, learning_rate controls how fast the network learns. This value scales the size of the weight updates
during training. By increasing this value, we get the network to learn faster but we risk overshooting optimal values.
Lower values are safer, but slower.
We initialize weights randomly and biases to zero. The small network has two hidden units.
Training Loop
We run a “forward pass” and a “backward pass” many times (we refer to these as epochs).
Forward pass
The forward pass takes the input X, feeds it through the network layer by layer, and computes the output a2. Then it
calculates how far off the prediction is using a loss function.
In this step, we are calculating the loss for the current set of weights.
This loss is a measure of how “wrong” the network is, and it’s what drives the learning process in the backward pass.
Backward pass
The backward pass is how the network learns—by adjusting the weights based on how much they contributed to the final
error. This is done by applying the chain rule in reverse across the network.
# Step 1: Derivative of loss with respect to output (a2)
d_loss_a2=2*(a2-y)/y.size
This computes the gradient of the mean squared error loss with respect to the output. It answers: How much does a
small change in the output affect the loss?
# Step 2: Derivative of sigmoid at output layer
d_a2_z2=sigmoid_derivative(a2)d_z2=d_loss_a2*d_a2_z2
Now we apply the chain rule. Since the output passed through a sigmoid function, we compute the derivative of the
sigmoid to see how a change in the pre-activation \(z_2\) affects the output.
# Step 3: Gradients for W2 and b2
d_W2=np.dot(a1.T,d_z2)d_b2=np.sum(d_z2,axis=0,keepdims=True)
a1.T is the transposed output from the hidden layer.
d_z2 is the error signal coming back from the output.
The dot product calculates how much each weight in W2 contributed to the error.
The bias gradient is simply the sum across all samples.
# Step 4: Propagate error back to hidden layer
d_a1=np.dot(d_z2,W2.T)d_z1=d_a1*sigmoid_derivative(a1)
Now we move the error back to the hidden layer:
d_a1 is the effect of the output error on the hidden layer output.
We multiply by the derivative of the hidden layer activation to get the true gradient of the hidden pre-activations.
# Step 5: Gradients for W1 and b1
d_W1=np.dot(X.T,d_z1)d_b1=np.sum(d_z1,axis=0,keepdims=True)
X.T is the input data, transposed.
We compute how each input feature contributed to the hidden layer error.
This entire sequence completes one application of backpropagation—moving from output to hidden to input layer,
using the chain rule and computing gradients at each step.
The final gradients (d_W1, d_W2, d_b1, d_b2) are then used in the weight update step:
# Apply the gradients to update the weights
W2-=learning_rate*d_W2b2-=learning_rate*d_b2W1-=learning_rate*d_W1b1-=learning_rate*d_b1
This updates the model just a little bit—nudging the weights toward values that reduce the overall loss.
The network is getting better, but not perfect. Let’s look at what these predictions mean:
Input
Expected
Predicted
Interpreted
[0, 0]
0
0.1241
0
[0, 1]
1
0.4808
~0.5
[1, 0]
1
0.8914
1
[1, 1]
0
0.5080
~0.5
It’s nailed [1, 0] and is close on [0, 0], but it’s uncertain about [0, 1] and [1, 1]. That’s okay—XOR is a
tough problem when learning from scratch with minimal capacity.
This ambiguity is actually a great teaching point: neural networks don’t just “flip a switch” to get things right.
They learn gradually, and sometimes unevenly, especially when training conditions (like architecture or learning rate)
are modest.
You can tweak the hidden layer size, activation functions, or even the optimizer to get better results—but the core
algorithm stays the same: forward pass, loss computation, backpropagation, weight update.
Conclusion
As it stands, this tiny XOR network is a full demonstration of what makes neural networks learn.
Rust programmers encounter combinators all the time: map(), and_then(), filter(). They’re everywhere in
Option, Result, Iterator, and of course, Future. But if you’re coming from a functional programming
background — or just curious how these things work — you might ask:
What actually is a combinator?
Let’s strip it down to the bare minimum: a value, a function, and some deferred execution.
A Lazy Computation
We’ll start with a structure called Thunk. It wraps a closure that does some work, and it defers that work until we
explicitly ask for it via .run().
It’s essentially a one-shot deferred computation. We stash a closure inside, and we invoke it only when we’re ready.
Here, F is the type of the closure (the function) we’re wrapping, and R is the result it will produce once called. This lets
Thunk be generic over any one-shot computation.
The work here is really wrapped up by self.f.take() which will force the value.
Simple.
Example
Here’s what this looks like in practice:
fnmain(){letadd_one=Thunk::new(||3+1);letresult=add_one.run();println!("Result: {}",result);// prints 4}
No magic. No threading. No async. Just a delayed function call.
Composing Thunks
The real value in combinators is that they compose. We can make more complex computations out of simpler ones —
without immediately evaluating them.
Here’s how we can build on top of multiple Thunks:
We’ve built a new computation (o) that depends on two others (m and n). They won’t run until o.run() is
called — and then they run in the correct order, and just once.
Look Familiar?
If you’ve spent time in Haskell, this structure might look suspiciously familiar:
fmap :: Functor f => (a -> b) -> f a -> f b
This is a form of fmap. We’re not building a full trait implementation here, but the shape is the same. We can even
imagine extending Thunk with a map() method:
No typeclasses, no lifetimes — just combinator building blocks.
From Lazy to Async
Now here’s the twist. What if our .run() method couldn’t give us a value right away? What if it needed to register a
waker, yield, and be polled later?
That’s exactly what happens in Rust’s async system. The structure is the same — a value and a function bundled
together — but the execution context changes. Instead of calling .run(), we implement Future and respond to
.poll().
Here’s what that looks like for a simple async Map combinator:
usestd::future::Future;usestd::pin::Pin;usestd::task::{Context,Poll};usepin_project::pin_project;// Our Map combinator#[pin_project]pubstructMap<Fut,F>{#[pin]future:Fut,f:Option<F>,// Option to allow taking ownership in poll}impl<Fut,F>Map<Fut,F>{pubfnnew(future:Fut,f:F)->Self{Self{future,f:Some(f)}}}impl<Fut,F,T,U>FutureforMap<Fut,F>whereFut:Future<Output=T>,F:FnOnce(T)->U,{typeOutput=U;fnpoll(self:Pin<&mutSelf>,cx:&mutContext<'_>)->Poll<Self::Output>{letmutthis=self.project();matchthis.future.poll(cx){Poll::Pending=>Poll::Pending,Poll::Ready(val)=>{letf=this.f.take().expect("polled Map after completion");Poll::Ready(f(val))}}}}// Helper function to use it ergonomicallypubfnmap<Fut,F,T,U>(future:Fut,f:F)->Map<Fut,F>whereFut:Future<Output=T>,F:FnOnce(T)->U,{Map::new(future,f)}
Let’s take a step back and notice something: this structure is almost identical to Thunk. We’re still storing a value
(future) and a function (f), and the combinator (Map) still controls when that function is applied. The
difference is that we now interact with the asynchronous task system via poll(), instead of calling .run() ourselves.
This is how Future combinators in futures and tokio work under the hood — by carefully pinning, polling, and
composing smaller futures into larger ones.
This is essentially a hand-rolled version of what futures::FutureExt::map() gives you for free.
As a simple example, we can use this as follows:
#[tokio::main]asyncfnmain(){letfut=async{21};letmapped=map(fut,|x|x*2);letresult=mapped.await;println!("Result: {}",result);// Should print 42}
Conclusion
We often think of combinators as “just utility functions.” But they’re really more than that: they’re
a way of thinking. Package a value and a transformation together. Delay the work. Compose more when you’re ready.
So the next time you write .map(), remember — it’s just a Thunk waiting to happen.
FUSE is a powerful Linux kernel module that lets you implement your own filesystems entirely in user space. No
kernel hacking required. With it, building your own virtual filesystem becomes surprisingly achievable and even… fun.
In today’s article, we’ll build a filesystem that’s powered entirely by HTTP. Every file operation — reading a
file, listing a directory, even getting file metadata — will be handled by a REST API. On the client side, we’ll
use libcurl to perform HTTP calls from C, and on the server side, a simple Python Flask app will serve as our
in-memory file store.
Along the way, you’ll learn how to:
Use FUSE to handle filesystem operations in user space
Make REST calls from C using libcurl
Create a minimal RESTful backend for serving file content
Mount and interact with your filesystem like any other directory
Up in my github repository I have added this project if you’d like to pull it down and try
it. It’s called restfs.
Let’s get into it.
Defining a FUSE Filesystem
Every FUSE-based filesystem starts with a fuse_operations struct. This is essentially a table of function pointers —
you provide implementations for the operations you want your filesystem to support.
This tells FUSE: “When someone calls stat() on a file, use restfs_getattr. When they list a directory, use
restfs_readdir, and so on.”
Let’s break these down:
getattr: Fills in a struct stat with metadata about a file or directory — size, mode, timestamps, etc. It’s the equivalent of stat(2).
readdir: Lists the contents of a directory. It’s how ls knows what to show.
open: Verifies that a file can be opened. You don’t need to return a file descriptor — just confirm the file exists and is readable.
read: Reads data from a file into a buffer. This is where the real I/O happens.
Each function corresponds to a familiar POSIX operation. For this demo, we’re implementing just the basics — enough to
mount the FS, ls it, and cat a file.
If you leave an operation out, FUSE assumes it’s unsupported — for example, we haven’t implemented write, mkdir,
or unlink, so the filesystem will be effectively read-only.
Making REST Calls from C with libcurl
To interact with our HTTP-based server, we use libcurl, a powerful and flexible HTTP client library for C. In
restfs, we wrap libcurl in a helper function called http_io() that performs an HTTP request and returns a parsed
response object.
CURLOPT_CUSTOMREQUEST lets us specify GET, POST, PUT, DELETE, etc.
If a body is provided (e.g. for POST/PUT), we pass it in using CURLOPT_POSTFIELDS.
CURLOPT_WRITEFUNCTION and CURLOPT_WRITEDATA capture the server’s response into a buffer.
Headers are added manually to indicate we’re sending/expecting JSON.
After the call, we extract the HTTP status code and clean up.
The result is returned as a _rest_response struct:
struct_rest_response{intstatus;json_object*json;char*data;// raw response bodysize_tlength;// response size in bytes};
This makes it easy to access either the full raw data or a parsed JSON object depending on the use case.
To parse the JSON responses from the server, we use the json-c library — a
lightweight and widely used C library for working with JSON data. This allows us to easily extract fields like
st_mode, st_size, or timestamps directly from the server’s responses.
To simplify calling common HTTP methods, we define a few handy macros:
This layer abstracts away the curl boilerplate so each FUSE handler can focus on interpreting the result.
The Backend
So far we’ve focused on the FUSE client — how file operations are translated into HTTP requests. But for the system to
work, we need something on the other side of the wire to respond.
Enter: a minimal Python server built with Flask.
This server acts as a fake in-memory filesystem. It knows nothing about actual disk files — it just stores a few
predefined paths and returns metadata and file contents in response to requests.
Let’s look at the key parts:
A Python dictionary (fs) holds a small set of files and their byte contents.
The /getattr endpoint returns a JSON version of struct stat for a given file path.
The /readdir endpoint lists all available files (we only support the root directory).
The /read endpoint returns a slice of the file contents, based on offset and size.
This is enough to make ls and cat work on the mounted filesystem. The client calls getattr and readdir to
explore the directory, and uses read to pull down bytes from the file.
End to End
With the server running and the client compiled, we can now bring it all together.
Start the Flask server in one terminal:
python server.py
Then, in another terminal, create a mountpoint and run the restfs client:
Now try interacting with your mounted filesystem just like any other directory:
➜ restmnt ls -l
total 1
-rw-r--r-- 1 michael michael 6 Jan 1 1970 data.bin
-rw-r--r-- 1 michael michael 15 Jan 1 1970 hello.txt
➜ restmnt cat hello.txt
Hello, RESTFS!
You should see logs from the server indicating incoming requests:
Under the hood, every file operation is being translated into a REST call, logged by the Flask server, and fulfilled
by your in-memory dictionary.
This is where the whole thing becomes delightfully real — you’ve mounted an HTTP API as if it were a native part of
your filesystem.
Conclusion
restfs is a fun and minimal example of what FUSE can unlock — filesystems that aren’t really filesystems at all.
Instead of reading from disk, we’re routing every file operation over HTTP, backed by a tiny REST server.
While this project is intentionally lightweight and a bit absurd, the underlying ideas are surprisingly practical.
FUSE is widely used for things like encrypted filesystems, network mounts, and user-space views over application state.
And libcurl remains a workhorse for robust HTTP communication in C programs.
What you’ve seen here is just the start. You could extend restfs to support writing files, persisting data to disk,
mounting a remote object store, or even representing entirely virtual data (like logs, metrics, or debug views).
Sometimes the best way to understand a system is to reinvent it — badly, on purpose.