--- title: "Linking Expat from another package" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Linking Expat from another package} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} knitr::opts_chunk$set(collapse = TRUE, comment = "#>") ``` ## Who this is for Read this if your package has C code that is written against **Expat itself** --- it calls `XML_ParserCreate()`, `XML_GetBuffer()`, `XML_StopParser()` --- and you want it to build without a system `libexpat`. Typically the code is vendored and cannot be rewritten: `xlsxio`, which `zuxlsx` vendors, is that case. If instead you are writing new C code that you control, **stop here and read `vignette("streaming")`** first. The registered function table described there is the better path for new code: it needs no `configure`, no linker flags, and picks up a zuxml fix when zuxml is upgraded. This vignette is for the case where that is not an option. ## The two modes | | table (`zuxml_api`) | archive (`libzuxml.a`) | |------------------------|----------------------------------|---------------------------------| | `DESCRIPTION` | `Imports:` **and** `LinkingTo:` | `LinkingTo:` only | | `NAMESPACE` | needs an `importFrom()` | nothing | | Header | `zuxml.h`, no Expat type in it | `expat.h`, Expat's own API | | API style | callbacks through a table | everything Expat offers | | Symbols resolved | at run time, `R_GetCCallable()` | at link time, into your `.so` | | zuxml at run time | installed **and** loadable | not needed at all | | A zuxml fix reaches you| when zuxml is upgraded | when *you* are reinstalled | The archive exists because the table cannot express a *pull*-style reader. A reader that returns a row to its caller and resumes where it left off is built on `XML_StopParser()`/`XML_ResumeParser()`, and there is no equivalent in `zuxml.h`. ## What an installed zuxml gives you ```{r} # system.file() returns "" outside an installed layout, and dir("") would then # list the working directory rather than nothing. installed <- function(what) { path <- system.file(what, package = "zuxml") if (nzchar(path)) dir(path) else "(not an installed layout)" } installed("include") arch <- .Platform$r_arch installed(if (nzchar(arch)) file.path("lib", arch) else "lib") ``` The archive installs under `lib${R_ARCH}`, as the shared object does under `libs${R_ARCH}`. `R_ARCH` is empty on most platforms, which makes that plain `lib`. `LinkingTo: zuxml` puts that `include` directory on your compiler's path (R appends it to `CLINK_CPPFLAGS`), so `#include ` resolves with no further work. The headers are not kept under `inst/include/` in zuxml's sources. They are copied out of the vendored tree at install time by `src/install.libs.R`, so the header you compile against is by construction the one the objects in the archive were compiled from. ## There is no `LinkingTo` for a library `LinkingTo` handles headers and nothing else. For the archive you have to find its directory yourself, and the only portable place to do that is a `configure` script that writes `src/Makevars`. The two shortcuts are both worse. `$(shell ...)` in `Makevars` forces `SystemRequirements: GNU make`. An `Imports: zuxml` entry gets you nothing: `Imports` is about loading a namespace at run time, which a statically linked consumer never does. ### `configure` ```sh #!/bin/sh set -eu : "${R_HOME:?configure must be run by R CMD INSTALL, which sets R_HOME}" RSCRIPT="${R_HOME}/bin/Rscript" # lib/ first, then lib: right whether R sets an architecture or not, # and whichever layout the installed zuxml uses. lib_dir() { "${RSCRIPT}" --vanilla -e "arch <- .Platform\$r_arch; d <- if (nzchar(arch)) system.file('lib', arch, package = '$1') else ''; if (!nzchar(d)) d <- system.file('lib', package = '$1'); cat(d)" } ZUXML_LIB=$(lib_dir zuxml) if [ -z "${ZUXML_LIB}" ] || [ ! -f "${ZUXML_LIB}/libzuxml.a" ]; then echo "configure: libzuxml.a was found in neither zuxml/lib/ nor zuxml/lib." >&2 exit 1 fi sed -e "s|@ZUXML_LIB@|${ZUXML_LIB}|g" src/Makevars.in > src/Makevars ``` Check for the **file**, not for a version. An installed zuxml from before the archive existed reports the same version as one that carries it, so its presence on disk is the only honest test. `configure.win` is the same script ending in `src/Makevars.win`, and a `cleanup` script should remove both, since they are generated: ```sh #!/bin/sh rm -f src/Makevars src/Makevars.win ``` ### `src/Makevars.in` ```make PKG_CPPFLAGS = -DXML_STATIC PKG_LIBS = '@ZUXML_LIB@/libzuxml.a' ``` Two details that are easy to get wrong and fail only on one platform: * **`-DXML_STATIC` is recommended, not required.** It says what is true --- the library is linked statically --- and it keeps the headers from marking declarations `__declspec(dllimport)`. Stock `expat_external.h` does that only for Microsoft's compiler (`_MSC_VER`), which Rtools is not, so a build without the define links today. Keep it anyway: it costs nothing and survives a change of toolchain. * **Quote the path.** It comes from `system.file()`, so it lives under the R library, and on Windows the user library sits under the profile directory --- a path with a space in it reaches the linker as two nonexistent arguments. R quotes its own `LinkingTo` include paths for the same reason. ### `DESCRIPTION` ``` LinkingTo: zuxml ``` That is all. No `Imports: zuxml`, and nothing in `NAMESPACE`. ## Which Expat you are getting The archive holds the Expat objects zuxml's own shared object was linked from, and **nothing else** --- no `zux_*` wrapper, no R glue, since either would be dead weight or a duplicate symbol inside your shared object. It is compiled with zuxml's feature policy, which is stricter than a distro build. Your code inherits the compiled-in half of that policy: * **`XML_GE 0`, and `XML_DTD` never defined.** General entities, parameter entities, external subsets and the external-entity machinery are compiled out, so XXE and entity amplification are impossible rather than disabled. In a document with no `DOCTYPE`, any entity reference other than the five built-ins and numeric character references is a parse error. With one, it may not be: see the next section. * **Do not define `XML_GE=1` on your own command line.** `expat.h` would then declare `XML_SetBillionLaughsAttackProtection*()`, which the archive does not define, and you would get a link error. Those limiters are pointless here anyway --- the attack they bound is already compiled out. * **`XML_NS 1`.** Namespace-aware parsing via `XML_ParserCreateNS()` works. * **`XML_CONTEXT_BYTES 1024`**, so `XML_GetInputContext()` is available. * **`XML_UNICODE` is not defined**, so `XML_Char` is plain `char` and input is UTF-8. `XML_LARGE_SIZE` and `XML_ATTR_INFO` are not defined either. ## What you do not inherit The rest of zuxml's policy is not compiled into Expat. It lives in zuxml's own parser, above Expat, which is exactly the code an archive consumer does not call. So `XML_ParserCreate()` on the archive gives you none of it: * **No `DOCTYPE` rejection.** zuxml refuses a `DOCTYPE` by default, and an internal subset always. That matters more than it looks. With `XML_GE 0`, Expat does not record entity declarations, so in a document that declares `` in an internal subset, a reference `&e;` does not fail: it arrives in your character-data handler as the four characters `&e;`. The document is silently wrong. A `DOCTYPE` that names an external subset makes Expat skip undeclared references instead of failing on them. * **No resource limits.** zuxml's `max_depth`, `max_nodes`, `max_attrs` and `max_text` are counted in its own handlers. Expat bounds none of them. If your input can be hostile, install the same guards yourself. Rejecting a `DOCTYPE` is one handler, which is what zuxml does in `src/zux_parser.c`: ```c static void XMLCALL reject_doctype(void *user, const XML_Char *name, const XML_Char *sysid, const XML_Char *pubid, int has_internal_subset) { XML_Parser p = (XML_Parser)user; (void)name; (void)sysid; (void)pubid; (void)has_internal_subset; /* Record why, then stop: XML_Parse() returns XML_STATUS_ERROR. */ XML_StopParser(p, XML_FALSE); } /* after XML_ParserCreate(): */ XML_SetUserData(p, p); /* or your own struct holding p */ XML_SetStartDoctypeDeclHandler(p, reject_doctype); ``` To accept a `DOCTYPE` but refuse an internal subset, as zuxml's `allow_doctype = TRUE` does, stop only when `has_internal_subset` is non-zero. Bound depth by counting in your start- and end-element handlers, and text by counting in your character-data handler, stopping the parser the same way. **Security fixes reach you only when you rebuild.** Your copy of Expat is linked into your shared object. When zuxml updates its bundled Expat, for a security fix or otherwise, your users get the fix only once your package is rebuilt and re-released against the new zuxml. R does not rebuild `LinkingTo` dependents on upgrade. ## Check that you actually linked it On Linux and Windows a wrong `PKG_LIBS` fails at link time. **On macOS it does not.** R links a package shared object with `-undefined dynamic_lookup`, so a consumer that never linked the archive still builds, still loads, and still parses --- against whatever Expat happens to be in the process, usually the system one. Every behavioural test passes. The only reliable check is the symbol table: ```sh # Nothing may be left for the loader to resolve. nm -u path/to/yourpkg.so | grep XML_ # And Expat has to actually be in there. nm path/to/yourpkg.so | grep ' [TtDd] _*XML_' | wc -l ``` ## A worked example [`tools/zuxmltest/`][fixture] in zuxml's development sources is a complete, minimal package in exactly this shape --- `configure`, `configure.win`, `cleanup`, `src/Makevars.in`, and a `src/consume.c` that drives `XML_GetBuffer`/`XML_ParseBuffer` with a suspend/resume round trip. [`tools/run-downstream-check`][gate] installs it against a freshly built zuxml and asserts all of the above, including that it still parses after zuxml has been removed from the library path. Both live in the git repository rather than in the installed package, since `tools/` is not shipped. [fixture]: https://github.com/pedrobtz/zuxml/tree/main/tools/zuxmltest [gate]: https://github.com/pedrobtz/zuxml/blob/main/tools/run-downstream-check