At the moment this is alpha software. While it passes basic unit tests and everything appears to work (on my Windows 10 registry at least), there is no guarantee that it will work with the registry hive you are examining. The binary format for the registry is not public, can change without notice, and may behave in ways that are not documented anywhere. If you run into an issue, please submit a bug report.
Known issues:
- At the moment regtools does not support deserializing
bd
or "Big Data" values. None of the registry hives I have examined have any big data values as far as I can tell, so while it would be very easy to implement this, I need to find a registry file that definitely does contain them in order to test/develop the feature. - Timestamps might be off by a number of leap seconds. Need to find a way to check this.
- Error messages aren't very helpful.
If you find regtools
useful, or have a suggestion for a new feature or change, please open an issue and let me know. (If it's feasible and doesn't require an asinine amount of work, I will probably add a feature if you request it, even if you're the only one that uses this!)
regtools
is a command-line utility + tiny, simple scripting language (written in Haskell) for forensic exploration of Windows registry hive bins (binary) files. Its features include an interactive shell for live exploration of registry keys and values, a plugin system to facilitate sharing and rerunning queries, the ability to output the results of queries (or the entire registry, if you really hate having free disk space) to JSON, and a tool for hashing select registry keys.
You should be able to compile + run regtools
on any modern linux platform. It may compile on Windows (there are no platform-specific libraries) but is not actively targeted at Windows at this time.
regtools
attempts to parse the entirety of a windows registry hive and load it into memory before running queries on it. You'll need an amount of free RAM equal to (the size of the file you are loading) * 10-20-ish. This will come down with optimization, but you probably have more than enough RAM, but don't try to run it on a toaster.
It scales pretty well with the number of cores you have. On my Ryzen 3900x, it takes about 3 seconds to deserialize a 45mb hive file with all 12 cores, and around 30 with only one core in GHCI.
To install regtools
you will first need a version of the Glasgow Haskell Compiler (GHC) and the Haskell Stack (stack) package manager.
It is highly suggested that you obtain a copy of stack via the ghcup Haskell installer. (Especially if you are on Arch Linux, I believe.) After you've installed, run ghcup tui
, highlight any version of "Stack" that you see (if it doesn't appear, enter t
to show all tools), then enter i
to install.
Once you have that installed, clone the reposity and then:
cd regtools
stack install
You might want to go make a sandwich or something - it'll take a while to compile the dependencies. If you don't see any error messages when you get back, you should now have a copy of regtools installed to ~/.local/bin
. Either move the regtools
binary to a directory on your path, or add ~/.local/bin
to your path.
(If you run into any issues with stack, check here for assistance.)
regtools
has three modes of operation:
- Shell mode, which provides an interactive shell for exploring the registry, and can be accesed via:
regtools -i "/path/to/hive/bin" shell
- Plugin mode, which reads a regtools plugin and runs it:
regtools -i "/path/to/hive/bin" plugin "/path/to/plugin/file"
- Hash check mode, which reads a hash of registry keys generated by
regtools
and prints the result to the terminal:
regtools -i "/path/to/hive/bin" checkHash "/path/to/hash/file"
regtools
provides a (very) small DSL for querying a registry hive, printing the results. The syntax is inspired by bash style pipes. Here's an example of a regtools
query:
query root (subkeys | select (keyNameHas "Software") | concatMap (subkeys) | select (keyNameHas "Adobe") )
a b c d e
This is a query expression. All query expressions have the form:
query <focus> (viewer1 arg1 | viewer2 arg2 arg3 | <etc>)
The <focus>
is either a variable (see the section on Assignments), or root
. (Most of the time it will be root
.)
viewer1
, viewer2
, etc are view functions, which are chained together with |
in the manner of bash scripts. The output of viewer1
is fed into viewer2
, and the output of that is fed into viewer3
if there is one, etc.
(Terminological note: arg1
, arg2
etc are the arguments to the view function, whereas the output of the previous view function (or the focus if it is the first view function) are input.)
Here's what the example query expression does:
a) query root
indicates that the query will be run against the root (first, top-level) key of a registry. Because this "focuses" on the root query, we call root
the focus.
b) subkeys
focus on a list of all of the subkeys of its input. Since we are focusing on the root key, it gives us all of the (immediate) subkeys of the root key.
c) select (keyNameHas "Software")
(parentheses are mandatory with select
) operates on a list of keys and removes every key from the list that does not satisfy the condition of its argument.
keyName
takes a string as its argument and matches on any key in the input list which contains the string in its name. I.e. it performs substring search on the key names it is inspecting.
keyName's argument is case sensitive
d) concatMap
takes a view function that produces a list as its argument, and a list as its input, and produces a list as its output. The main point of this is to avoid having to deal with lists of lists of lists (of lists...). It might seem complicated at first but it's really not so bad.
Here, concatMap (subkeys)
(the parentheses aren't optional for concatMap
) takes the subkeys
view function as an argument, which has a single key as its input and produces a list of keys as its output, and applies subkeys
to every key in its input list, then "squishes" the list of lists into a list.
e) Same thing as C. Remember: Performs a substring search on all keys in a list of keys and filters out those that do not contain the string "Adobe"
(See the reference at the end of this file for an explanation of all of the commands.)
If you run the example query in the previous section, you will notice that it does not produce any output to the terminal. This is intentional! Some queries may return a very large number of results, and it would take far longer to print them to the terminal than it takes the computer to calculate them.
To print the output to the terminal, you can use the (appropriately named) print
command like so:
> print (query root (subkeys | select (keyNameHas "Software") | concatMap (subkeys) | select (keyNameHas "Adobe") ) )
[
| Key Name: Adobe
| Path: S-1-5-21-3199533274-2294187411-1904285961-1001_Classes\Software\
| TimeStamp: Sat, 13 Mar 2021 14:01:34 UTC
| Values: <NONE>
| Subkeys: <TRUNCATED>
|------]
But that's kind of ugly and the parentheses make it hard to read, so, alternatively, you can assign the result of a query to a variable with a let
expression, like so:
> let x = query root (subkeys | select (keyNameHas "Software") | concatMap (subkeys) | select (keyNameHas "Adobe") )
> print x
[
| Key Name: Adobe
| Path: S-1-5-21-3199533274-2294187411-1904285961-1001_Classes\Software\
| TimeStamp: Sat, 13 Mar 2021 14:01:34 UTC
| Values: <NONE>
| Subkeys: <TRUNCATED>
|------]
Two things to note here:
First, *a variable can only be assigned once in a single shell session or script file. If you know an imperative programming language, you can think of regtools
variables as constants. (They're really immutable variables, a la Haskell/Rust or Scala's 'val's, but the difference doesn't really matter here.)
One thing to note here is that the regtools
scripting language is strongly/statically typed. If you don't know what the means (or are scared of types), don't worry! There are only 5 types:
-
REGKEY
, which represents a registry key -
VAL
, which represents a registry value -
BOOL
(i.e. True/False) -
LIST
s, e.g.LIST REGKEY
orLIST VAL
. Lists inregtools
must contain elements which are all the same type.
The 5th type is called EFFECT
and you can mostly ignore it. (If you're familiar with functional programming, it's equivalent to ()
in Haskell or unit
in purescript or... I forget what it's called in Scala. regtools
commands are just expressions that return EFFECT
.) This is the return type of commands and exists for the purposes of keeping the internal type system coherent.
You do not ever have to annotate the type. Indeed, it is impossible to do so! regtools
can deduce the type of every valid expression. If you aren't sure what the type of an expression is , you can use the showType
command in the shell, and regtools
will tell you the type:
> let x = query root (subkeys | select (keyNameHas "Software") | concatMap (subkeys) | select (keyNameHas "Adobe") )
> showType x
LIST REGKEY
This tells you that the variable x
refers to a list of RegistryKey values. Note that checking the type like this does not "run" (evaluate) the expression.
For the most part, all you need to keep track of is whether your expressions return a Registry Key, a list of Registry Keys, or a list of Registry Values.
As mentioned above, regtools
allows users to create plugins, which execute a series of queries and (potentially) print the results to the terminal or output them to a file. To define a plugin, simply create a text file with the following format:
PLUGIN {
<Your queries/commands>
}
Plugins can only be run from the command line, a la the above example (repeated here):
regtools -i "/path/to/hive/bin" plugin "/path/to/plugin/file"
Technically speaking, the regtools
scripting language is completely insensitive to indentation / extra whitespace. But you should probably only put one expression or command on each line (for your own sanity).
As an example of a plugin file, here's the in-language test suite I run when making changes to regtools
(it should give you a decent idea of how these work):
PLUGIN {
printStr "Test #1 - Basic Query (subkeys, select, keyNameHas)"
let someKeys = query root (subkeys | select (keyNameHas "mhtml"))
printStr "Test #2 - `print`"
print someKeys
printStr "Test #3 - `writeJSON`"
writeJSON someKeys "/home/gsh/testJSON"
printStr "Test #4 - `writeHash`"
writeHash someKeys "/home/gsh/testHASH"
printStr "Test #5 - `checkHash`"
checkHash "/home/gsh/testHASH"
printStr "Test #6 - `showType`"
showType someKeys
printStr "Test #7 - `key` (no <$ROOT$>)"
print query root (key ".mhtml")
printStr "Test #8 - `key` (just <$ROOT$>)"
print query root (key "<$ROOT$>")
printStr "Test #8 - `key` (with <$ROOT$>)"
let atMostOneKey = query root (key "<$ROOT$>\.mhtml")
print atMostOneKey
printStr "Test #9 - `values`, `valNameHas`, `concatMap`"
print query root (subkeys | concatMap (values) | select (valNameHas "backup") )
printStr "Test #10 - `valDataHas`"
print query root (subkeys | concatMap (values) | select (valDataHas "audio") )
printStr "Test #12 - `expand`, var queries, line-break insensitivity, `map`"
print (
query atMostOneKey (map (expand))
)
printStr "Test #13 - `append`"
let hasMHTML = query root (subkeys | select (keyNameHas "mhtml"))
let hasPHTML = query root (subkeys | select (keyNameHas "phtml"))
print (append hasMHTML hasPHTML)
printStr "Test #14 - `concat`"
let toConcat = query atMostOneKey (map (subkeys))
print (concat toConcat)
printStr "Test #15 - `isEmpty`, if-then"
if isEmpty toConcat
then printStr "Empty toConcat"
else printStr "Non-empty toConcat"
printStr "All tests succeeded"
}
Note that parentheses are largely optional outside of view-function expressions.
It is probably good practice to use relative paths when writing a plugin if you plan on sharing it, as it is quite unlikely that the person running it has the same home directory path as you!
(If enough people end up using regtools
and request the feature, I will enable calling plugins from within the shell. There is no technical reason why this couldn't be done, but I am not sure that there is much use for it at the moment.)
This section of the readme contains all of the view functions regtools
supports, the input/output type of each view function, and examples of their use. The input output type will be designated with an arrow - e.g. REGKEY -> LIST REGKEY
indicates that the view function takes a Registry Key as input and outputs a list of Registry Keys.
Type: REGKEY -> LIST VAL
No arguments
Example:
> let vsCodePhtmlVals = query root (subkeys | select (keyNameHas "VSCode.phtml") | map (values) )
> print vsCodePhtmlVals
[[|-----------
|- Value Path: <$ROOT$>\VSCode.phtml
|- Value Name: AppUserModelID
|- Value Data: REG_SZ [Microsoft.VisualStudioCode]
,|-----------
|- Value Path: <$ROOT$>\VSCode.phtml
|- Value Name:
|- Value Data: REG_SZ [PHP HTML Source File]]]
values
does not take any arguments. It receives a Registry Key as its input and outputs a list of the values the key contains.
Type: REGKEY -> REGKEY
One optional integer argument
Before explaining this, note the <TRUNCATED>
in the following example:
> let myRootKey = query root (key "<$ROOT$>")
> print myRootKey
[
| Key Name: S-1-5-21-3199533274-2294187411-1904285961-1001_Classes
| Path: \
| TimeStamp: Tue, 24 Aug 2021 01:30:41 UTC
| Values: <NONE>
| Subkeys: <TRUNCATED>
|------]
(The key
view function returns a list for reasons that will be explained in its section.)
Note: <$ROOT$>
is the special path to the root key of a hive.
The Windows registry has a tree-like (logical) structure. Many higher-level keys contain an incredibly large number of subkeys. Because of this, regtools
does not, by default, include subkeys in the results of queries or in the output to files or the terminal.
expand
takes a Registry Key and fetches its subkeys recursively up to the depth of the optional argument. (Unfortunately I can't find a reasonably sized example so hopefully the following helps!) For instance, the query:
query myRootKey (map (expand 1))
Will evaluate to the root node of our registry as before, but instead of <TRUNCATED>
, the Subkeys
field now contains all of the root key's immediate children (each of which will have <TRUNCATED>
in its Subkeys
field, because we only expanded to a depth of 1).
If you call expand
without an argument, it expands a key as much as it can. So, if you wanted to write an entire hive bin to JSON for instance, you could do so with:
let theWholeHive = query myRootKey (map (expand))
writeJSON theWholeHive "/hope/u/have/lotsa/free/space" "don't do this"
(I strongly discourage this; the file size is disgustingly large compared to the binary hive. I cannot think of a good reason to dump a whole hive to JSON.)
Type: REGKEY -> LIST REGKEY
No arguments
Gets a list of subkeys of the query target. Another example here probably wouldn't serve much purpose since this is used in most of the other ones!
Type: REGKEY -> LIST REGKEY
One argument: A string literal, enclosed in quotes
Example:
> print (query root (key ".mhtml"))
[
| Key Name: .mhtml
| Path: <$ROOT$>\
| TimeStamp: Sat, 7 Dec 2019 09:51:11 UTC
| Values:
|-----------
|- Value Name: Content Type
|- Value Data: REG_SZ [message/rfc822]
|-----------
|- Value Name:
|- Value Data: REG_SZ [mhtmlfile]
| Subkeys: <TRUNCATED>
|------]
key
takes a quoted string that represents a path to one of its child (or grandchild, or great-great-grandchild, etc) keys as an argument and a Registry Key as input, and searches for the key at the given path. Two quirks:
- If you call
key
as the first part of a query expression that starts at the root (i.e.query root (key "KEYPATH")
), as in the above example, then the first part of the path should not be the root key of the registry but the subkey that you wish to search for. You can use the special path placeholder<$ROOT$>
to target the root key itself if you wish. In short, the queries
(query root (key ".mhtml"))
and
(query root (key "<$ROOT$>\.mhtml"))
are equivalent.
- The second quirk is that this returns a list rather than a specific key, even though it will never return more than one key. I apologize for this, but it has to be this way. The brief reason why is that
regtools
uses some fairly advanced type-level programming tricks (if you know Haskell, look at the Magic.hs source file in the Explore folder) to "borrow" the Haskell compiler's type checker, and a consequence of those tricks is that theregtools
MUST crash if an expression in the DSL doesn't return some value. Which means the shell would crash every time a key you're looking for isn't found. Which is bad. Sokey
returns an empty list if the key is not found.
Type: REGKEY -> BOOL
One argument: A string literal enclosed between quotes
Example:
> print query root (subkeys | select (keyNameHas "blue") )
[
| Key Name: ms-settings-bluetooth
| Path: <$ROOT$>\
| TimeStamp: Sat, 7 Dec 2019 09:15:36 UTC
| Values:
|-----------
|- Value Name: URL Protocol
|- Value Data: REG_SZ []
|-----------
|- Value Name:
|- Value Data: REG_SZ [URL:ms-settings-bluetooth]
| Subkeys: <TRUNCATED>
|------]
keyNameHas
is generally used only as an argument to select
. It performs a (pretty fast) substring search on a Registry Key's name and returns true if its argument is contaiend in the name, or false if it's not.
NOTE: I have assumed that all key names are encoded in UTF8. If that is wrong, this won't work properly. I have not discovered a counterexample but if you are aware of one please let me know.
Type: VAL -> BOOL
One argument: A string literal enclosed between quotes
Same thing as keyNameHas
but for values instead of keys so I'm not bothering with an example here. Same encoding problem. I believe the encoding problem might be fixable in this context but I need to do some more research.
Type: VAL -> BOOL
One argument: A string literal enclosed between quotes
Unlike keyNameHas
, this view function converts the input string to the correct format depending on the value. That being said, there is no guarantee that value data is actually encoded in the format that the VK record says it is, so this might fail in cases where it seems like it should work if the software that created the key was poorly written (or well written, but malicious!).
Note that while the substring search algorithm used is efficient enough for small values, I am not sure how well this function will handle very large pieces of value data.
Type: LIST a -> LIST b (See the arguments + explanation)
One argument: A view function of type a -> b
, where a
and b
are any of the types in the language. (Except EFFECT)
Reusing a previous example:
query root (subkeys | map (expand 1))
map
is a higher-order view function. It takes one argument: Any other view function. When it receives a LIST
of type a
, it applies its a -> b
view function argument to each element of LIST a
and yields a LIST b
.
If it helps, you can think of it as a kind of for
loop that does something to every element of a list. If the type stuff confused you, it's used in several examples in this document, and looking at those might be better than starting at the types.
The parentheses around the argument are mandatory.
Type: LIST a -> LIST a (See the arguments + explanation)
One argument: A view function of type a -> Bool
, where a
is any of the types in the language. (Except EFFECT)
Example:
query root (subkeys | select (keyNameHas "yolo") )
This is pretty self explanatory even if the types are confusing. Again, it's in most of the examples, which are probably more useful for getting a feel for how it works.
The parentheses around the argument are mandatory.
Type: LIST a -> LIST a (See the arguments + explanation)
One argument: A view function of type a -> LIST b
, where a
and b
are any of the types in the language. (Except EFFECT)
Example (this is probably what you'll use it for 99% of the time):
query root (subkeys | concatMap (values) )
subkeys
spits out a list of REGKEY
s. values
takes a REGKEY
and spits out a list of values. Suppose that we want a list of all of the values in our entire list of subkeys, we can use concatMap
to apply our values
view function to the list of values subkeys
gave us, then "squish" the result from a list to a list-of-lists.
Again, the example should make this clear even if the types look weird or strange.
The parentheses around the argument are mandatory.
In order to facilitate very basic scripting, regtools
offers a few helper functions and control expressions. These are probably useful primarily if you are writing plugins.
Note that these are not view functions. You cannot use them inside a query expression. I.e. query root (<ANYTHING IN THIS SECTION>)
will throw an error.
Example:
> let someKeys = query root (subkeys | select (keyNameHas "VSCode.phtml"))
> print someKeys
[
| Key Name: VSCode.phtml
| Path: <$ROOT$>\
| TimeStamp: Tue, 24 Aug 2021 01:30:38 UTC
| Values:
|-----------
|- Value Name: AppUserModelID
|- Value Data: REG_SZ [Microsoft.VisualStudioCode]
|-----------
|- Value Name:
|- Value Data: REG_SZ [PHP HTML Source File]
| Subkeys: <TRUNCATED>
|------]
> print (append someKeys someKeys)
[
| Key Name: VSCode.phtml
| Path: <$ROOT$>\
| TimeStamp: Tue, 24 Aug 2021 01:30:38 UTC
| Values:
|-----------
|- Value Name: AppUserModelID
|- Value Data: REG_SZ [Microsoft.VisualStudioCode]
|-----------
|- Value Name:
|- Value Data: REG_SZ [PHP HTML Source File]
| Subkeys: <TRUNCATED>
|------
,
| Key Name: VSCode.phtml
| Path: <$ROOT$>\
| TimeStamp: Tue, 24 Aug 2021 01:30:38 UTC
| Values:
|-----------
|- Value Name: AppUserModelID
|- Value Data: REG_SZ [Microsoft.VisualStudioCode]
|-----------
|- Value Name:
|- Value Data: REG_SZ [PHP HTML Source File]
| Subkeys: <TRUNCATED>
|------]
I suppose you won't usually use append to combine two copies of the same list, but hopefully the example still works!
append
combines two lists. The contents of each list must have the same type. The primary use of append
is to combine the results of various queries before writing JSON / hash-JSON / printing.
Note that in this example, the variable someKeys
has been assigned to a List of Registry Keys. (subkeys
outputs a list and select
filters a list to a potentially smaller list.) That list just happens to contain only one element.
Example:
> let someOtherKeys = query root (subkeys | select (keyNameHas "VSCode.phtml") | map (values) )
> print someOtherKeys
[[|-----------
|- Value Path: <$ROOT$>\VSCode.phtml
|- Value Name: AppUserModelID
|- Value Data: REG_SZ [Microsoft.VisualStudioCode]
,|-----------
|- Value Path: <$ROOT$>\VSCode.phtml
|- Value Name:
|- Value Data: REG_SZ [PHP HTML Source File]]]
> showType someOtherKeys
LIST (LIST VAL)
> print (concat someOtherKeys)
[|-----------
|- Value Path: <$ROOT$>\VSCode.phtml
|- Value Name: AppUserModelID
|- Value Data: REG_SZ [Microsoft.VisualStudioCode]
,|-----------
|- Value Path: <$ROOT$>\VSCode.phtml
|- Value Name:
|- Value Data: REG_SZ [PHP HTML Source File]]
> showType (concat anotherKey)
LIST VAL
concat
takes a LIST OF LISTS OF SOMETHING and turns it into a LIST OF SOMETHING. I think the example more or less speaks for itself.
Example (using the same variable as concat
)
> print (isEmpty someOtherKeys)
False
isEmpty
takes a list of any kind of value and returns True
if the list is empty and False
if it is not empty.
Example:
> if (isEmpty anotherKey) then printStr "Key not found" else writeJSON anotherKey "/home/gsh/testJSON2"
(... prints out the JSON representation because the first argument evaluates to False ...)
if-then-else
expressions provide the sole mechanism for control flow in regtools
scripts. They mostly work like how you'd expect, with one caveat if your primary experience is in a dynamically typed language:
The then
branch and the else
branch must both have the same type!
In the above example, both branches are commands, which have the special EFFECT
type. You can use if-then-else
expressions with things other than commands, but you will get an error if the branches differ in type. You probably don't need this unless you are writing plugins.
Other than print
and showType
, the other regtools
commands are:
Reminder: Commands and view functions are distinct from each other and cannot be used interchangable. E.g. this is WRONG!!!!!: query root (subkeys | print)
exit
closes the shell or stops execution of a plugin script. I assume that an example would be superfluous.
Example:
> printStr "hello"
hello
Prints a string (which must be enclosed in quotes) to the terminal. This is mainly useful for writing plugins (e.g. to indicate to the plugin user whether a key exists or not).
Examples:
> let someKeys = query root (subkeys | select (keyNameHas "mhtml"))
> writeJSON someKeys "/home/me/testJSON"
Array
[ Object
( fromList
[
( "Key Name"
, String "mhtmlfile"
)
,
( "Path"
, String "<$ROOT$>"
)
,
( "Subkeys"
(...etc)
> writeJSON someKeys "/home/me/testJSON" "myTag"
Object
( fromList
[
( "myTag"
, Array
[ Object
( fromList
[
( "Key Name"
, String "mhtmlfile"
)
,
( "Path"
, String "<$ROOT$>"
)
,
( "Subkeys"
(... etc)
writeJSON
, which takes two mandatory arguments and one optional argument. The first (mandatory) argument is a variable or expression of type REGKEY
, LIST REGKEY
, or LIST VAL
. (Technically you can also write BOOLs to a file, but I'm not sure what the point would be.) The second mandatory argument is the output file path to write the JSON representation of a Key/List of Keys/List of Values. The third (optional) argument is a string which you can use to tag the JSON object. (The example should make the use of the optionaal third argument clear.)
Example:
let someKeys = query root (subkeys | select (keyNameHas "mhtml"))
> writeHash someKeys "/home/me/myHash"
ManyKeys
[ KeyHash
{ _hshKeyName = "<$ROOT$>\mhtmlfile"
, _timeHash = "89e6358b107557efbce5a6579ef556db"
, _valuesHash =
[ ValHash
{ _vHashName = "FriendlyTypeName"
, _vHashPath = "<$ROOT$>\mhtmlfile"
, _vHashData = "9dc82a59684ff57d72e381e68a731e63"
}
, (... etc ...)
writeHash
writes a JSON representation of an MD5-hashed version of the first argument to the file at the second argument. The first argument must have the type REGKEY
, LIST REGKEY
, or LIST VAL
or you will get an error. As with writeJSON
, the output you see in the terminal is a Haskell representation of JSON, but it should give you enough of an idea of the structure to work with it yourself.
NOTE: This overwrites any existing files.
I will probably remove the terminal output for writeHash/writeJSON at some point, or provide an option to silence it.
Example:
> checkHash "/home/gsh/testHASH"
<$ROOT$>\mhtmlfile - Values changed
The value named FriendlyTypeName located at <$ROOT$>\mhtmlfile has changed.
Hash check complete (If you didn't get any output then everything matched).
checkHash
checks a hash file generated by writeHash
and reports any mismatches. (Should be pretty self explanatory.)
On the off chance that anyone using this knows Haskell/Purescript, you can use >>>
(the left-to-right categorical composition operator) instead of |
. E.g.:
query root (subkeys >>> select (keyNameHas "\f x -> f (f x)"))
There's no real reason for this other than the fact that I think it looks better.