DEV Community

Cover image for Playing with Rust: Building a Safer rm and Having Fun Along the Way
Douglas Makey Mendez Molero
Douglas Makey Mendez Molero

Posted on • Originally published at kungfudev.com

Playing with Rust: Building a Safer rm and Having Fun Along the Way

Welcome to my YOLO series, where I'll be showcasing simple tools and projects that I've built—sometimes for fun, sometimes to solve specific problems, and other times just out of pure curiosity. The goal here isn't just to present a tool; I'll also dive into something interesting related to the process, whether it's a technical insight or a lesson learned while crafting these little experiments.

Introducing rrm: The Command-Line Tool Nobody Asked For

Nobody asked for it, and nobody want it—but here it is anyway. Meet rrm, a tool that solves a problem only I seem to have (but hey, it might be a Layer 8 issue—or, more likely, a skill issue!).

rrm adds a layer of safety to your command-line experience by moving files to a trash bin instead of permanently deleting them. With a customizable grace period, you get the chance to realize, "Oops, I actually needed that!" before it’s too late.

What’s more, rrm doesn’t rely on external configuration files or tracking systems to manage deleted files. Instead, it leverages your filesystem’s extended attributes to store essential metadata—like the original file path and deletion time—directly within the trashed item.

You might be wondering, "Why am I building this tool when there are similar, possibly better tools out there?" Well, the answer is simple:

  • I wanted to play with Rust. Building small, purposeful tools is a great way to explore a language and sharpen skills.
  • like developing my own CLI tools as a way to create a mental framework. It helps me consistently approach how I structure command-line utilities for specific technologies. By building these tools, I refine my understanding of what dependencies to use, how to organize the code, and how to adapt each tool to the language’s ecosystem. It’s a way of building a mental playbook for creating CLI tools that suit my needs.
  • Because YOLO. I enjoy making simple tools or proof-of-concepts around problems I want to solve or things I’m curious about. Sometimes, it’s about experimenting for the sake of learning.

Fun note: While working with std::Path, I found an example in the Rust standard library that uses a folder named laputa. I know it's a reference to Castle in the Sky, but for Spanish speakers, it’s also a curse word, which made it a funny moment for me!

Extended Attributes: Storing Metadata Without Changing the File

When I started building rrm, I needed a way to track the original path of deleted files and the time when they should be permanently removed. I didn’t want to use a JSON file or implement a weird naming format that includes this information—especially if I wanted to store more data later. A database felt like overkill for such a small task.

That’s when I discovered extended attributes.

What Are Extended Attributes?

Now, I don’t know about you, but I didn’t realize there was a built-in mechanism that lets you add custom metadata to files, which is supported by most Linux filesystems and Unix-like systems such as macOS. This feature is called Extended File Attributes. Different systems have their own limitations—like how much data can be added or the specific namespaces used—but they do allow you to store user-defined metadata.

Extended attributes are essentially name:value pairs that are permanently associated with files and directories. As I mentioned earlier, systems differ in how they handle this. For example, in Linux, the name starts with a namespace identifier. There are four such namespaces: security, system, trusted, and user. In Linux, the name starts with one of these, followed by a dot (".") and then a null-terminated string. On macOS, things are a bit different. macOS doesn't require namespaces at all, thanks to its Unified Metadata Approach, which treats extended attributes as additional metadata directly tied to files without needing to be categorized.

In this tiny CLI, I’m using the crate xattr, which supports both Linux and macOS. Regarding the namespaces I mentioned earlier for Linux, we'll be focusing on the user namespace since these attributes are meant to be used by the user. So, in the code, you’ll see something like this:

/// Namespace for extended attributes (xattrs) on macOS and other operating systems.
/// On macOS, this is an empty string, while on other operating systems, it is "user.".
#[cfg(target_os = "macos")]
const XATTR_NAMESPACE: &str = "";
#[cfg(not(target_os = "macos"))]
const XATTR_NAMESPACE: &str = "user.";

...

    fn set_attr(&self, path: &Path, attr: &str, value: &str) -> Result<()> {
        let attr_name = format!("{}{}", XATTR_NAMESPACE, attr);
        ...
    }
Enter fullscreen mode Exit fullscreen mode

The #[cfg(target_os = "macos")] attribute in Rust is used to conditionally compile code based on the target operating system. In this case, it ensures that the code block is only included when compiling for macOS. This is relevant because, as mentioned earlier, macOS doesn’t require a namespace for extended attributes, so the XATTR_NAMESPACE is set to an empty string. For other operating systems, the namespace is set to "user.". This conditional compilation allows the code to adapt seamlessly across different platforms, making the CLI cross-compatible with both Linux and macOS.

One thing I found pretty cool about extended attributes is that they don’t modify the file itself. The metadata lives in a separate disk space, referenced by the inode. This means the file's actual contents remain unchanged. For example, if we create a simple file and use shasum to get its checksum:

The inode (index node) is a data structure in a Unix-style file system that describes a file-system object such as a file or a directory. Link

$ cat a.txt
https://www.kungfudev.com/

$ shasum a.txt
e4c51607d5e7494143ffa5a20b73aedd4bc5ceb5  a.txt
Enter fullscreen mode Exit fullscreen mode

After using rrm to delete the file, we can list the deleted files and see that the file has been moved to the trash bin with its metadata intact:

$ rrm rm a.txt

$ rrm list
╭──────────────────────────────────────────────────────┬──────────────────────────────────────┬──────┬─────────────────────╮
│ Original Path                                        ┆ ID                                   ┆ Kind ┆ Deletion Date       │
╞══════════════════════════════════════════════════════╪══════════════════════════════════════╪══════╪═════════════════════╡
│ /Users/douglasmakey/workdir/personal/kungfudev/a.txt ┆ 3f566788-75dc-4674-b069-0faeaa86aa55 ┆ File ┆ 2024-10-27 04:10:19 │
╰──────────────────────────────────────────────────────┴──────────────────────────────────────┴──────┴─────────────────────╯
Enter fullscreen mode Exit fullscreen mode

As you can see, the file name is changed to a UUID. This is done to avoid name collisions when deleting files with the same name. By assigning a unique identifier to each file, rrm ensures that every deleted file, even if they have identical names, can be tracked and recovered without any issues.

We can navigate to the trash folder and inspect the file to confirm that its contents remain unchanged:

$ shasum 3f566788-75dc-4674-b069-0faeaa86aa55
e4c51607d5e7494143ffa5a20b73aedd4bc5ceb5  3f566788-75dc-4674-b069-0faeaa86aa55
Enter fullscreen mode Exit fullscreen mode

Additionally, by using xattr on macOS, we can verify that the file has its metadata, such as the deletion date and original path:

$ xattr -l 3f566788-75dc-4674-b069-0faeaa86aa55
deletion_date: 2024-10-27T04:10:19.875614+00:00
original_path: /Users/douglasmakey/workdir/personal/kungfudev/a.txt
Enter fullscreen mode Exit fullscreen mode

You can imagine the range of potential use cases for simple validations or actions using this metadata. Since extended attributes work without modifying the file itself, they allow you to check file integrity or perform other operations without affecting the original content.

This is just a small introduction to extended attributes and how they’re used in this project. It’s not meant to be an in-depth explanation, but if you’re interested in learning more, there are plenty of detailed resources out there. Here are a couple of links to the most useful and well-described resources on the topic:

Mocking in Rust: Exploring mockall for Testing

I’ve spent a few years working with Go, and I’ve become fond of certain patterns—mocking being one of them. In Go, I typically implement things myself if it avoids unnecessary imports or gives me more flexibility. I’m so used to this approach that when I started writing tests in Rust, I found myself preferring to manually mock certain things, like creating mock implementations of traits.

For example, in this tiny CLI, I created a trait to decouple the trash manager from the way it interacts with the extended attributes. The trait, named ExtendedAttributes, was initially intended for testing purposes, but also because I wasn’t sure whether I would use xattr or another implementation. So, I defined the following trait:

pub trait ExtendedAttributes {
    fn set_attr(&self, path: &Path, key: &str, value: &str) -> Result<()>;
    fn get_attr(&self, path: &Path, key: &str) -> Result<Option<String>>;
    fn remove_attr(&self, path: &Path, key: &str) -> Result<()>;
}
Enter fullscreen mode Exit fullscreen mode

In Go, I would create something like the following, which provides a simple implementation of the previously mentioned interface. The code below is straightforward and generated without much consideration, just for the sake of example:

// ExtendedAttributes allows for passing specific behavior via function fields
type ExtendedAttributes struct {
    SetAttrFunc    func(path string, key string, value string) error
    GetAttrFunc    func(path string, key string) (string, error)
    RemoveAttrFunc func(path string, key string) error
}

// SetAttr calls the injected behavior
func (m *ExtendedAttributes) SetAttr(path string, key string, value string) error{
    return m.SetAttrFunc(path, key, value)
}

// GetAttr calls the injected behavior
func (m *ExtendedAttributes) GetAttr(path string, key string) (string, error) {
    return m.GetAttrFunc(path, key)
}

// RemoveAttr calls the injected behavior
func (m *ExtendedAttributes) RemoveAttr(path string, key string) error {
    return m.RemoveAttrFunc(path, key)
}

Enter fullscreen mode Exit fullscreen mode

Then, I would use my mock and inject the specific behavior needed for each test. Again, this is simple code just for the sake of the example:

func TestSetAttrWithMock(t *testing.T) {
    mock := &ExtendedAttributes{
        SetAttrFunc: func(path string, key string, value string) error {
            if path == "/invalid/path" {
                return errors.New("invalid path")
            }
            return nil
        },
    }

    err := mock.SetAttr("/invalid/path", "key", "value")
    if err == nil {
        t.Fatalf("expected error, got nil")
    }

    err = mock.SetAttr("/valid/path", "key", "value")
    if err != nil {
        t.Fatalf("expected no error, got %v", err)
    }
}
Enter fullscreen mode Exit fullscreen mode

I've gotten used to this pattern in Go, and I plan to keep using it. But I’ve also been doing something similar in Rust. For this project, I decided to try the mockall crate, and I found it really useful.

First, I used the mock! macro to manually mock my structure. I know mockall has an automock feature, but I prefer to define the mock struct directly in my tests where it will be used. Let me know if this is something common or if the community has a different standard for this.

mod test {
...
    mock! {
        pub XattrManager {}
        impl ExtendedAttributes for XattrManager {
            fn set_attr(&self, path: &std::path::Path, key: &str, value: &str) -> crate::Result<()>;
            fn get_attr(&self, path: &std::path::Path, key: &str) -> crate::Result<Option<String>>;
            fn remove_attr(&self, path: &std::path::Path, key: &str) -> crate::Result<()>;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

I found mockall really useful, allowing me to inject specific behaviors into my tests without the verbosity of my old pattern.

    #[test]
    fn test_trash_items() -> Result<()> {
        ...
        let mut xattr_manager = MockXattrManager::new();
        xattr_manager
            .expect_set_attr()
            .with(
                in_iter(vec![original_path.clone(), original_path2.clone()]),
                in_iter(vec![ORIGINAL_PATH_ATTR, DELETION_DATE_ATTR]),
                in_iter(vec![
                    original_path_str,
                    original_path2_str,
                    deletion_date.to_rfc3339().to_string(),
                ]),
            )
            .times(4)
            .returning(|_, _, _| Ok(()));
        ...
    }

    #[test]
    fn clean_trash_delete_old_file() {
        ...
        xattr_manager
            .expect_get_attr()
            .times(2)
            .returning(move |_, key| match key {
                DELETION_DATE_ATTR => Ok(Some(deletion_date_past.to_rfc3339())),
                _ => Ok(Some("some_path".to_string())),
            });
        ...
    }
Enter fullscreen mode Exit fullscreen mode

As we can see, mockall gives us the capability to define specific behaviors for our tests using its mock methods:

  • MockXattrManager::new() This creates a new instance of the mock object MockXattrManager, which is used to mock the behavior of XattrManager for testing.
  • xattr_manager.expect_set_attr() This sets up an expectation that the set_attr method will be called during the test. You define the expected behavior of this method next.
  • with(...) The with method specifies the expected arguments when set_attr is called. In this case, it’s expecting three arguments and uses in_iter to indicate that each argument should match one of the values in the provided vector. This allows flexibility in the test, as it checks if the arguments are one of the values from the passed vector rather than a single exact match.
  • times(4) This specifies that the set_attr method is expected to be called exactly four times during the test.
  • returning(|_, _, _| Ok(())) This tells the mock what to return when set_attr is called. In this case, it returns Ok(()) regardless of the arguments (|_, _, _| means the arguments are ignored). This simulates the successful execution of set_attr.

Some of you might find this super basic or not that interesting, but as I mentioned, in this YOLO series, I’m sharing things that I find interesting or just want to talk about. I wasn’t a big fan of using this kind of library in Go, partly due to Go’s constraints, but in Rust, I found mockall really useful. It even reminded me of my old days with Python.

Again, this section wasn’t meant to explain mocking in Rust or mockall. I’m sure there are plenty of great resources that cover it in detail. I just wanted to mention it briefly.

To conclude

In this post, I’ve shared some of the reasoning behind building rrm and the tools I used along the way. From using extended attributes to simplify metadata handling to experimenting with the mockall crate for testing in Rust, these were just things that piqued my interest.

The goal of this YOLO series is to highlight the fun and learning that comes with building even simple tools. I hope you found something useful here, and I look forward to sharing more projects and insights in future posts. As always, feedback is welcome!

Happy coding!

Top comments (0)