intertwingly

It’s just data

Nokogiri, Loofah and Crass, Compiled


I built these for Roundhouse, which compiles Rails applications, and Campfire needs all three: Action Text sanitizes every message through rails-html-sanitizer, which is built on Loofah, which is built on Nokogiri and Crass. But none of them need Rails, and I suspect they are more useful on their own than inside a compiled Rails app.

What they are

Spinel is Matz's ahead-of-time compiler for a subset of Ruby, and spin is its package tool. Here's a complete program:

require "loofah"

# Untrusted HTML on stdin, safe HTML on stdout.
html = $stdin.read
puts Loofah.html5_fragment(html).scrub!(:strip).scrub!(:nofollow).to_s
spin new sanitize && cd sanitize
spin add loofah --git https://github.com/rubys/spinel-loofah --ref v0.1.0
# put the program above in bin/sanitize.rb
spin build
echo '<p onclick="x()">Hi<script>alert(1)</script> <a href="https://example.com">l</a></p>' \
  | ./build/bin/sanitize
# <p>Hialert(1) <a href="https://example.com" rel="nofollow">l</a></p>

That's a standalone executable. The libxml2 and gumbo sources are compiled into it, so the machine that runs it needs no Ruby, no gems and no libxml2:

macOS (Apple M4 Max) Linux (Ryzen 5 3600, Ubuntu 24.04)
executable 2.2 MB (2.0 stripped) 3.3 MB (3.0 stripped)
links against libSystem libc, libm, libcrypt
one run, compiled 5.4 ms 9.1 ms
same program, CRuby 4.0.5 + the gems 68 ms 89 ms

Each time is the average of 200 runs on the same small document, so it's mostly startup. Both programs print identical output. That makes these a good fit for command-line filters, git hooks, static-site build steps, feed readers, and anything else that runs once per input.

How I know the answers are right

These aren't approximations of the gems. Loofah and Crass are the gems' own source. Where Spinel can't compile something, there's a rewrite: nine in Loofah and two in Crass. Each one is marked in the code, explained in the README, and visible as a diff against a first commit that is the gem, verbatim.

The tests are the gems' own inputs. I ran Loofah's test suite under CRuby with a recorder on Loofah's entry points, and kept every String the suite passed in: about a thousand HTML fragments, 17 documents, 91 style values and 212 URIs. Each fragment goes through both parsers and every built-in scrubber, in the compiled package and in the real gem. That's about 25,000 lines of output, and they match byte for byte. Crass is held to Simon Sapin's css-parsing-tests, and three real stylesheets must parse and serialize back to the same bytes. Nothing in the expected output was written by hand.

The corpus found real differences, and all of them are fixed:

What's different

The READMEs list the details.

Try them

They're version 0.x, with index entries pending in spin-index. Until those merge, add them by git URL as shown above. If you find an input where the compiled package and the gem disagree, that's a bug; please file it with the input.

Next is rails-html-sanitizer, compiled unmodified on top of these. Then Campfire's rich-text messages go through the same code in both lanes, the Ruby one and the compiled one.