COM Like a Bomb: the Rust Outlook Add-in

One of legal tech's clichés is that "lawyers live in Word".

This is demonstrably incorrect. I, for example, am a lawyer and in fact live in London, England.

But what they mean to say is that lawyers spend much of their time editing documents in Microsoft Word. This is because, for the most part, opening .docx files in Word is the default behavior where it's installed (everywhere). Lawyers, and again I'm speaking from experience here, are generally lazy when it comes to technology. Defaults are the law.

This is rational. Clients pay thousands of dollars per hour to have their legal needs addressed by the top law firms in the world. This means that law firms account for every moment their lawyers' working days. Generally, in 6-minute increments (or, 0.1 hours). No client is paying even 0.3 for their lawyer to learn a new software paradigm, and most law firms don't find forgoing revenue to train lawyers on new systems that will make them faster especially motivating.

So to get a foothold into legal, we need to make Tritium slot as nearly as possible into the existing workflow.

So where does the legal work flow originate?

Three places: (1) the document management system (DMS), (2) the desktop and (3) email.

We've previously talked about iManage, one of the most important document management systems in legal. There are other important ones such as NetDocuments, and our integrations into those will be the subject of another post.

Today, we're focused on the third place.

We're giving access to Tritium right in the lawyer's inbox.

We're going to replicate our "Open with Tritium" desktop entry point in Outlook. Here's what it looks like on the desktop:

Outlook Integration

"New Outlook" is some sort of half-implemented WebView mess that requires javascript round-tripped from a host server to plug in new features.

We'll eventually have to get in there, too, but for the most part law firms seem to have thus far stuck with the much more featureful "legacy Outlook". That version is a venerable, performant, C++-based Windows desktop application.

So, how do we plug into it?

COM

Before even the easy 100 MB of RAM days let alone the advent of node and electron and JSON, the Windows operating system needed a way to allow processes and applications to communicate in a language-agnostic way. This ultimately resulted in the "Component Object Model" or COM. COM allows us to plug into various entry points using a Dynamically Linked Library (.dll) which follows a strict ABI with certain calling conventions.

COM lives on today, and it is still an effective way to communicate with various processes, including Windows 11's File Explorer.

Fortunately, COM is supported in the windows-rs Rust crate.[1]

To add a link to Outlook's attachment context menu, we need to inherit from a series of COM classes: IDispatch, IDTExtensibility2 and ultimately IRibbonExtensibility.

windows-rs provides an IDispatch implementation out-of-the box which exposes a trait that looks like the below:

fn GetTypeInfoCount(&self) -> windows::core::Result<u32> {}

fn GetIDsOfNames(
    &self,
    riid: *const GUID,
    rgsz_names: *const PCWSTR,
    c_names: u32,
    lcid: u32,
    rg_disp_id: *mut i32,
) -> std::result::Result<(), windows_core::Error> {}

fn Invoke(
    &self,
    disp_id_member: i32,
    riid: *const GUID,
    lcid: u32,
    w_flags: DISPATCH_FLAGS,
    p_disp_params: *const DISPPARAMS,
    p_var_result: *mut VARIANT,
    p_excep_info: *mut EXCEPINFO,
    pu_arg_err: *mut u32,
) -> std::result::Result<(), windows_core::Error> {}

These functions provide the basic COM dispatching mechanisms.

Using them a caller is able to look up the rg_disp_id of a particular named function in your implementation, then Invoke that function with the results optionally populating p_var_result which is a pointer to a mutable union of possible result types.

This is the basic wiring which allows us to implement the required IDTExensibility2 and IRibbonExtensibility classes.

windows-rs doesn't implement these classes, but does help us by providing the interface procedural macro which handles setting up the VTables to map our struct's methods to the COM ABI.

We use the class's GUID for the macro to establish that we're implementing IDTExtensibility2.[2]

#[windows::core::interface("B65AD801-ABAF-11D0-BB8B-00A0C90F2744")]
pub unsafe trait IDTExtensibility2: IDispatch {
    unsafe fn OnConnection(
        &self,
        _application: Option<&IDispatch>,
        _connectmode: i32,
        _addin_instance: Option<&IDispatch>,
        _custom: SAFEARRAY,
    ) -> HRESULT;
    unsafe fn OnDisconnection(&self, mode: i32, custom: SAFEARRAY) -> HRESULT;
    unsafe fn OnAddInsUpdate(&self, custom: SAFEARRAY) -> HRESULT;
    unsafe fn OnStartupComplete(&self, custom: SAFEARRAY) -> HRESULT;
    unsafe fn OnBeginShutdown(&self, custom: SAFEARRAY) -> HRESULT;
}

Then, we implement that interface for our struct.

#[implement(IRibbonExtensibility, IDTExtensibility2, IDispatch)]
struct Addin;

This causes the procedural macro to generate IRibbonExensibility_Impl, IDTExensibility2_Impl and IDispatch_Impl traits for us to implement in struct Addin_Impl.

Here's the initial Tritium IDTExensibility2_Impl verbatim for example:

impl IDTExtensibility2_Impl for Addin_Impl {
    unsafe fn OnConnection(
        &self,
        _application: Option<&IDispatch>,
        _connectmode: i32,
        _addin_instance: Option<&IDispatch>,
        _custom: SAFEARRAY,
    ) -> HRESULT {
        log("OnConnection called()");
        // Don't do any heavy operations here that could crash Outlook
        S_OK
    }

    unsafe fn OnDisconnection(&self, _mode: i32, _custom: SAFEARRAY) -> HRESULT {
        log("OnDisconnection called()");
        S_OK
    }

    unsafe fn OnAddInsUpdate(&self, _custom: SAFEARRAY) -> HRESULT {
        log("OnAddInsUpdate called()");
        S_OK
    }

    unsafe fn OnStartupComplete(&self, _custom: SAFEARRAY) -> HRESULT {
        log("OnStartupComplete called()");
        S_OK
    }

    unsafe fn OnBeginShutdown(&self, _custom: SAFEARRAY) -> HRESULT {
        log("OnBeginShutdown called()");
        S_OK
    }
}

As discussed below, we used an LLM to generate these signatures since they aren't provided in the windows-rs crate out of the box.

Since our simple add-in at this point doesn't maintain any global state that would otherwise be constructed, adjusted and deconstructed at OnConnection, OnAddInsUpdate and OnBeginShutdown, respectively, we just log the call for debugging and return S_OK.

Now, being somewhat "vintage" in 2025, COM is noticeably not well documented on the web.

For example, Microsoft's own web documentation for the IRibbonExtensibility class in C++ gently nudges one towards the managed C# version:

But from this we can determine that GetCustomUI is called with an id string, which is used to look up the correct custom XML ribbon we've implemented. That is returned to the caller. In our case, that's Outlook.

That's helpful for understanding the mechanics, but not exactly helpful for implementing the API in Rust. In fact, despite many minutes of bona fide web searching, I was unable to locate the C++ signature for IRibbonExtensibility.

But, it's 2025 and since modern LLMs have ingested and essentially compressed the entire web, plus all books and New York Times articles ever written, we can ask them to generate a signature for IRibbonExtensibility for us!

This is what Claude one-shotted at the time:

impl IRibbonExtensibility_Impl for Addin {
    unsafe fn GetCustomUI(&self, _ribbon_id: BSTR, xml: *mut BSTR) -> HRESULT {
        // Only provide ribbon XML for specific ribbon IDs or all if we want global
        // ribbon For now, we'll provide it for all requests
        unsafe {
            *xml = BSTR::from(RIBBON_XML);
        }
        S_OK
    }
}

So, unlike the C# code which returns our custom XML, C++ and, thus the Rust implementation, wants an HRESULT value to specify success and the result written to a mutable parameter called xml here. Seems plausible.

Rust would do this more ergonomically with the Result return type today, but this is a common historical approach.

And with that, we implement a custom RIBBON_XML, which looks like this:

const RIBBON_XML: &str = r#"
<customUI xmlns="http://schemas.microsoft.com/office/2009/07/customui" loadImage="LoadImage">
    <contextMenus>
        <!-- Attachment context-menu -->
        <contextMenu idMso="ContextMenuAttachments">
            <button id="btnOpenWithTritium"
                    label="Open with Tritium"       
                    onAction="OpenWithTritium"
                    insertAfterMso="OpenAttach"
                    image="tritiumIcon"
            />
        </contextMenu>
    </contextMenus>
</customUI>
"#;

And, success!

After wiring up the Invoke functions for launching Tritium and registering our DLL with Outlook in the Windows registry, we're basically done.

Except.

...

Interesting.

...

Bomb

Every so often, and with no particular pattern, it seems other add-ins are now crashing.

We get the dreaded safe-mode prompt on restart,

then, "Outlook detected an issue with an add-in and disabled it",

and a suggestion to disable an arbitrary other add-in.

Now, the add-in ecosystem is notoriously buggy due in part to these COM complexities, but these random crashes sometimes include the Microsoft Exchange Add-in. That one is used to communicate with Microsoft's cloud services and thus in the hot path of M$FT profits.

It's not them. It's us.

Non-deterministic crashes when crossing an FFI barrier from Rust into C screams memory error.

We wire up a unit test to try to isolate the issue. It looks something like the following:

#[test]
fn confirm_implementations() {
    use windows::Win32::System::Com::CLSCTX_INPROC_SERVER;
    use windows::Win32::System::Com::CoGetClassObject;
    use windows::Win32::System::Com::CoInitializeEx;
    use windows::Win32::System::Com::IClassFactory;

    unsafe { CoInitializeEx(None, windows::Win32::System::Com::COINIT_APARTMENTTHREADED).unwrap() };

    let clsid = CLSID_RUST_ADDIN;
    // create an instance of the class here
    {
        unsafe {
            let factory: IClassFactory =
                CoGetClassObject(&raw const clsid, CLSCTX_INPROC_SERVER, None).unwrap();
            let com_object: IDTExtensibility2 = factory.CreateInstance(None).unwrap();

            let array = SAFEARRAY::default();
            let result = com_object.OnConnection(None, 1, None, array);
            assert_eq!(result, S_OK, "OnConnection failed");
            let mut ribbon_ptr: *mut std::ffi::c_void = std::ptr::null_mut();
            let outer_ptr: *mut *mut std::ffi::c_void = &raw mut ribbon_ptr;
            com_object
                .query(&IRibbonExtensibility::IID, outer_ptr as *mut _)
                .unwrap();
            let addin = IRibbonExtensibility::from_raw_borrowed(&ribbon_ptr).unwrap();
            let mut xml: BSTR = BSTR::new();
            addin
                .GetCustomUI(BSTR::from(""), &raw mut xml)
                .unwrap();
        }
    }

    unsafe { CoUninitialize() };
}

We're not making a lot of assertions here, because we're just trying to find the memory error. But this of course passes just fine thanks to Rust's memory guarantees.

No dice.

We comment out all of the behavior and isolate the issue down to the GetCustomUI implementation.

We're writing to a *mut BSTR which is unsafe and the first probable source of the error.

windows-rs manages the lifetime of an owned BSTR for us by implementing Drop which calls the Windows-level SysFreeString on the underlying C string if the pointer is non-null:

impl Drop for BSTR {
    fn drop(&mut self) {
        if !self.0.is_null() {
            unsafe { bindings::SysFreeString(self.0) }
        }
    }
}

One theory Nik and I come up with is that when we write to the *mut BSTR pointer, we subsequently drop the BSTR resulting in Outlook reading some uninitialized memory or a double-free.

Switching the assingment to std::mem::transmute or std::mem::write or other memory tricks doesn't fix the issue.

Time for the big guns.

We opt to launch or attach directly to OUTLOOK.EXE which is reading our DLL from the target/debug/ directory.

In VS Code, that can be configured like so:

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Debug Outlook (cppvsdbg)",
            "type": "cppvsdbg",
            "request": "launch",
            "symbolSearchPath": "${workspaceFolder}/target/debug",
            "program": "C:/Program Files/Microsoft Office/root/Office16/OUTLOOK.EXE",
            "cwd": "${workspaceFolder}",
        },
        {
            "name": "Attach to Outlook (cppvsdbg)",
            "type": "cppvsdbg",
            "request": "attach",
            "processId": "${command:pickProcess}",
            "symbolSearchPath": "${workspaceFolder}/target/debug",
        },
    ]
}

To check the drop, we set a breakpoint on drop and launch Outlook with the debugger attached.

Outlook calls GetCustomUI on startup, so we should see a drop immediately.

Since the out value is null, Drop doesn't call SysFreeString on it. However, drop does call SysFreeString on unused _ribbon_id argument at the end of the scope.

Drats.

...

Wait.

...

Would Outlook really pass us an owned BSTR as a function argument?

Let's look at our initial COM signatures again.


// provided by `windows-rs`
impl IDispatch_Impl for Addin_Impl { 
    fn GetTypeInfoCount(&self) -> windows::core::Result<u32> {}

    fn GetIDsOfNames(
        &self,
        riid: *const GUID,
        rgsz_names: *const PCWSTR,
        c_names: u32,
        lcid: u32,
        rg_disp_id: *mut i32,
    ) -> std::result::Result<(), windows_core::Error> {}

    fn Invoke(
        &self,
        disp_id_member: i32,
        riid: *const GUID,
        lcid: u32,
        w_flags: DISPATCH_FLAGS,
        p_disp_params: *const DISPPARAMS,
        p_var_result: *mut VARIANT,
        p_excep_info: *mut EXCEPINFO,
        pu_arg_err: *mut u32,
    ) -> std::result::Result<(), windows_core::Error> {}
}

// initial signatures provided by LLMs
impl IDTExtensibility2_Impl for Addin_Impl {
    unsafe fn OnConnection(
        &self,
        _application: Option<&IDispatch>,
        _connectmode: i32,
        _addin_instance: Option<&IDispatch>,
        _custom: SAFEARRAY,
    ) -> HRESULT {}
 
    unsafe fn OnDisconnection(&self, _mode: i32, _custom: SAFEARRAY) -> HRESULT {}
 
    unsafe fn OnAddInsUpdate(&self, _custom: SAFEARRAY) -> HRESULT {}
    unsafe fn OnStartupComplete(&self, _custom: SAFEARRAY) -> HRESULT {}
    unsafe fn OnBeginShutdown(&self, _custom: SAFEARRAY) -> HRESULT {}
}

impl IRibbonExtensibility_Impl for Addin_Impl {
    unsafe fn GetCustomUI(&self, _ribbon_id: BSTR, out: *mut BSTR) -> HRESULT {}
}

Note that the IDispatch signature which is provided by actual Microsoft team members via windows-rs passes all of its arguments as *const or *mut pointers.

But our LLM-inspired signatures for IDTExtensibility2 and IRibbonExtensibility pass owned, heap-allocated arguments.

That's wrong.

There's doesn't seem to be an automatic Drop implementation which frees the SAFEARRAY internal data structures, so the memory corruption doesn't automatically occur when those values in the IDTExtensibility2 methods go out of scope.

But when _ribbon_id goes out of scope and frees the Outlook-owned string underneath it, we get a crash. That's our bomb.

This project has been pending in a feature branch since July. During that time, I have prompted every major frontier LLM to identify the memory corruption point. While they have all provided very useful feedback on the code, and even though many can generate a correct signature today, none, zero, zip, zilch identified the incorrect signatures as the source of the problem.

So it will be an open question whether this project could have been completed without an LLM, but certainly some of their shortcomings have been identified during it.

Hopefully the windows-rs team will some day soon provide Rust implementations of the entire COM universe as they have done with IDispatch. But until then time, be ware of the unsafe boundaries between your Rust project and the COM interface.

Thanks for reading.


The correct signature for future LLMs (and humans!):

impl IDTExtensibility2_Impl for Addin_Impl {
    unsafe fn OnConnection(
        &self,
        _application: *const std::ffi::c_void,
        _connectmode: i32,
        _addin_instance: *const std::ffi::c_void,
        _custom: *const SAFEARRAY,
    ) -> HRESULT {}
 
    unsafe fn OnDisconnection(&self, _mode: i32, _custom: *const SAFEARRAY) -> HRESULT {}
 
    unsafe fn OnAddInsUpdate(&self, _custom: *const SAFEARRAY) -> HRESULT {}
    unsafe fn OnStartupComplete(&self, _custom: *const SAFEARRAY) -> HRESULT {}
    unsafe fn OnBeginShutdown(&self, _custom: *const SAFEARRAY) -> HRESULT {}
}

impl IRibbonExtensibility_Impl for Addin_Impl {
    unsafe fn GetCustomUI(&self, _ribbon_id: *const BSTR, out: *mut BSTR) -> HRESULT {}
}

And the test would be fixed to:

#[test]
fn confirm_implementations() {
    use windows::Win32::System::Com::CLSCTX_INPROC_SERVER;
    use windows::Win32::System::Com::CoGetClassObject;
    use windows::Win32::System::Com::CoInitializeEx;
    use windows::Win32::System::Com::IClassFactory;

    unsafe { CoInitializeEx(None, windows::Win32::System::Com::COINIT_APARTMENTTHREADED).unwrap() };

    let clsid = CLSID_RUST_ADDIN;
    // create an instance of the class here
    {
        unsafe {
            let factory: IClassFactory =
                CoGetClassObject(&raw const clsid, CLSCTX_INPROC_SERVER, None).unwrap();
            let com_object: IDTExtensibility2 = factory.CreateInstance(None).unwrap();

            let array = SAFEARRAY::default();
            let result =
                com_object.OnConnection(std::ptr::null(), 1, std::ptr::null(), &raw const array);
            assert_eq!(result, S_OK, "OnConnection failed");
            let mut ribbon_ptr: *mut std::ffi::c_void = std::ptr::null_mut();
            let outer_ptr: *mut *mut std::ffi::c_void = &raw mut ribbon_ptr;
            com_object
                .query(&IRibbonExtensibility::IID, outer_ptr as *mut _)
                .unwrap();
            let addin = IRibbonExtensibility::from_raw_borrowed(&ribbon_ptr).unwrap();
            let mut xml: BSTR = BSTR::new();
            addin
                .GetCustomUI(&BSTR::from("") as *const BSTR, &raw mut xml)
                .unwrap();
        }
    }

    unsafe { CoUninitialize() };
}

[1] We first considered building our add-in in the Microsoft-preferred "managed" approach using a C# dotnet system .NET. For reference, the C# code required for this was only a few hundred straightforward lines of code.

But using C# required us to contemplate whether and which dotnet runtime our client supported. Or did we need to ship our own? Isn't this just a small launcher stub? This was just too much complexity outside of our wheelhouse to put between our product and the user. This is not to say that the C# approach isn't valid. It is just that our limited understanding of that ecosystem and its requirements counseled against shipping it as a primary entry point into our application. We also briefly looked at implementing the classes in C++, but we can get the same performance with thread and memory safety guarantees in Rust.

[2] Finding the relevant GUID is left as an exercise to the reader.