Source code on docs.rs: ambiguous and obscure

I've just had an argument with someone whether docs.rs shows full platform-independent contents of a crate, and it turns out:

of course it does:

nope, it doesn't!

and both are true.

It's an unfortunate confusing combination.

Rustdoc's view is easier to discover - it's a "source" link visible by default right on the page you get under docs.rs/cratename. It's a fancy compiler-powered rendering with cross-linking and macro expansion. But being tied to compilation, it's sensitive to choice cfg() and collected after the crate's build has run, so it's not the full raw input to the compiler, but something that might have already been manipulated if a crate had sophisticated-enough malware.

There's a second source code view in a dropdown menu, which is generated more directly from files in cargo's tarball. This is a simpler, but more complete and more trustworthy view of the crate's contents.

The problem is that "view crate's source on docs.rs" could mean either one.


Supply chain attacks are a hot topic. I'm very concerned about this risk, and I see many Rust users as well as Rust-avoiders cite this risk as the biggest downside of the crates.io ecosystem. Being able to check authoritative source code of a crate is a must.

People unfamiliar with Cargo's/crates.io internals are doubly worried about malware in crates, because they perceive the tarball to be difficult to access, hidden from view, and disconnected from the source code view they can easily find: GitHub.

Having source easily accessible on docs.rs is great, but having two slightly different versions is like not being sure what time it is due to having two watches.

16 Likes

As of very recently, there's yet another source code view as a "Code" tab on crates.io.

https://crates.io/crates/tokio/1.52.3/code/src/lib.rs

3 Likes

I am not sure what else can be done but to raise awareness that "source" links on docs.rs show post-expansion code. We probably do not want to lose the various doc generation conveniences for code generated with build.rs and/or macros.

As noted above, the new "code" tab on crates.io could be sufficient as the go-to source for inspecting pre-expansion source code of published crates.

So, after the new crates.io code viewer was added, we might remove the docs.rs source viewer. The question remains about the rustdoc source view.

I can see that it might be confusing for users, but I'm not a security person, so:

What do you all think what would help users?

1 Like

Speaking as someone who often needs to dig into the source code of crates he didn't write, in order to deal with gaps in the documentation: I'm surprised to hear that the source code view on docs.rs isn't the same thing as what I'd see if I pulled the git tree and opened it locally. That makes the source code view on docs.rs substantially less useful to me. I recognize the value of cross-referencing and whatnot but I think I would actually trade that for "the source code view matches what's in version control as of the indicated release"!

(It sounds like in practice they will only be different in unusual and/or malicious situations, but ... it's still a problem.)

4 Likes

Expecting the code in git isn't really realistic, but you could expect the same code you'd get by unpacking the .crate.

1 Like

Why do you say that? Naively, having docs.rs be generated directly from the git tree corresponding to each release, rather than the .crate, should be a simple matter of programming (in all cases where the git repo is declared in the package metadata, anyway).

Even when you have a git repo link it's extremely difficult (finding the correct folder in multi-crate workspaces, finding the correct tag when the vcs info doesn't match a commit in the repo, finding the repo from the url (the url is more like "link to an html page that references the git repo", not all git hosts allow you to also clone from that same url), figuring out how to run arbitrary pre-publish steps).

All of those seem like they could in principle be fixed, and maybe should be fixed as long-term goals.

In the near term, though, presenting the code you'd get by unpacking the .crate would be better than what happens now.

1 Like

I've looked in detail into git<>crate correspondence and it's a mess:

  • there are crates with broken repo links, because nothing requires them to be working and long-lived. They get deleted or 404 due to username changes or ownership transfers (hosts like GitHub keep a redirect in some cases for some time, but not forever). Repos on non-github hosts are often dead due to domains lapsing, companies reorganizing their infra.

  • there are crates with links to wrong repos, usually due to forking an existing crate and keeping the original URL. Generally nothing requires the URL to be authentic, which could be dangerously misleading if users believe the git URL is the crate source.

  • git repos are not immutable. Tags can be changed. It's hard to enforce it externally, since a malicious repo author can modify the repo before/after an external check (running frequent checks for 300K+ crates would be awful lot of traffic).

  • commit hashes ensure immutability, but:

    • there are many crates published from a commit that never appears in the public repo (Could be private branches, temporary unpushed commits made during release process, or rebase).
    • the commit existing somewhere in the repo is not enough for supply chain security - a malicious author could stuff malware in an obscure ref (which may exist only for a brief moment when docs.rs fetches it) while keeping the main branch clean.
  • there are legitimate reasons for git and crate to differ: data files can be preprocessed, sources auto-generated, irrelevant files left out. We don't have a solution to handling this, other than Trusted Publishing that in practice forces everyone involved to depend on and trust Microsoft.

In the end you have to look at the same source that Cargo gets. Cargo doesn't get source from git, so trying to look at git will only make it a weaker, potentially misleading derivative. Making users aware where is the true content of a crate is technically way simpler than trying to build a secure chain between a git repo and what Cargo compiles (fetching via git protocol has a bunch of trust and perf issues)

19 Likes

A tiny sliver of hope, here, at least for a forge such as GitHub (other forges may not feature this, especially self-hostable ones!), is the concept of immutable releases.

For example:

Template:

OWNER="tokio-rs"
REPO="tokio"
TAG="tokio-1.52.3"

URL=https://api.github.com/repos/"${OWNER}"/"${REPO}"/releases/tags/"${TAG}"

curl -s "${URL}" | jq '.immutable'

But with that having been said, I'd still rather use the .vcs_commit_info metadata on the uploaded package and check that on Github. Maybe docs.rs could add such a button?

1 Like

On that front, here is a quick demo I've ended up with:

Click here to see the ugly details of this throwaway hack

:warning: I've had the LLM whip out, off some HTML node snippets I had found, the following Tampermonkey script (LLM beware :warning:):

  • Note: it's very ugly since on docs.rs I haven't found a raw way to access something such as tokio 1.52.3 - Docs.rs, so I queried the LLM to produce js logic (which I've not bothered verifying) that is supposed to parse it through the <code><pre> stuff… :melting_face:
// ==UserScript==
// @name         docs.rs Git Code Link
// @namespace    https://tampermonkey.net/
// @version      1.0
// @description  Adds a Git tree link using .cargo_vcs_info.json
// @author       You
// @match        https://docs.rs/*
// @grant        GM_xmlhttpRequest
// @connect      docs.rs
// ==/UserScript==

(function () {
    'use strict';

    async function main() {

        const menu = document.querySelector(".package-details-menu");
        if (!menu) {
            console.error("Failed to find `.package-details-menu`");
            return;
        }

        //------------------------------------------------------------
        // Extract crate + version
        //------------------------------------------------------------

        const title = menu.querySelector("#crate-title");
        if (!title) return;

        const text = title.childNodes[0].textContent.trim();

        // "safe-manually-drop 0.1.2"
        const m = text.match(/^(.+)\s+([^\s]+)$/);
        if (!m) return;

        const crate = m[1];
        const version = m[2];

        //------------------------------------------------------------
        // Extract repository URL
        //------------------------------------------------------------

        let repoLink = null;

        for (const a of menu.querySelectorAll("a")) {
            if (a.textContent.trim() === "Repository") {
                repoLink = a;
                break;
            }
        }

        if (!repoLink) {
            insertError("❌ NO REPOSITORY");
            return;
        }

        const repoUrl = repoLink.href;

        const githubMatch = repoUrl.match(
            /^https:\/\/github\.com\/([^\/]+)\/([^\/#?]+?)(?:\.git)?\/?$/
        );

        if (!githubMatch) {
            insertError("❌ NON-GITHUB REPO");
            return;
        }

        const owner = githubMatch[1];
        const repo = githubMatch[2];

        //------------------------------------------------------------
        // Fetch cargo_vcs_info
        //------------------------------------------------------------

        const infoURL =
            `https://docs.rs/crate/${crate}/${version}/source/.cargo_vcs_info.json`;

        let response;

        try {
            response = await fetch(infoURL);
        } catch (e) {
            insertError("❌ NO CARGO VCS INFO");
            return;
        }

        if (!response.ok) {
            insertError("❌ NO CARGO VCS INFO");
            return;
        }

        const html = await response.text();

        //------------------------------------------------------------
        // docs.rs syntax-highlights JSON, so grab the <pre> text.
        //------------------------------------------------------------

        const doc = new DOMParser().parseFromString(html, "text/html");

        const pre = doc.querySelector("#source-code pre");

        if (!pre) {
            insertError("❌ NO CARGO VCS INFO");
            return;
        }

        let json;

        try {
            json = JSON.parse(pre.textContent);
        } catch (e) {
            insertError("❌ BAD VCS INFO");
            return;
        }

        const sha = json?.git?.sha1;

        if (!sha) {
            insertError("❌ NO SHA");
            return;
        }

        const path = json.path_in_vcs || "";

        //------------------------------------------------------------
        // Build GitHub URL
        //------------------------------------------------------------

        let gitURL =
            `https://github.com/${owner}/${repo}/tree/${sha}`;

        if (path.length) {
            gitURL += "/" + path.replace(/^\/+/, "");
        }

        //------------------------------------------------------------
        // Insert menu entry
        //------------------------------------------------------------

        const li = document.createElement("li");
        li.className = "pure-menu-item";

        const a = document.createElement("a");
        a.className = "pure-menu-link";
        a.href = gitURL;
        a.target = "_blank";

        a.innerHTML =
            `<span class="fa fa-solid fa-code-branch" aria-hidden="true"></span> Git code`;

        li.appendChild(a);

        repoLink.parentElement.after(li);

        //------------------------------------------------------------

        function insertError(message) {

            const li = document.createElement("li");
            li.className = "pure-menu-item";

            li.innerHTML = `
                <span class="pure-menu-link"
                    style="
                        color:#d22;
                        font-weight:bold;
                        font-size:18px;
                    ">
                    ${message}
                </span>`;

            repoLink.parentElement.after(li);
        }
    }

    if (document.readyState === "loading") {
        window.addEventListener("DOMContentLoaded", main);
    } else {
        main();
    }

})();

An issue is that the code shown in docs.rs post-expansion might not be the code that runs in your own machine (because of differing #[cfg]s). If you are reading code to assess if a crate is trustworthy, that's a problem.

Auditing code after macro expansion is itself a pretty involved process, specially if you run code in multiple platforms and things like that. Maybe Rust tooling should be able to say "this piece of code is the same on all platforms" and "this actually changes between the two platforms you care about" etc.

3 Likes

I also have hope for such things (in addition to signed tags, signed pushes of tags, etc.). I think one also needs something like gittuf because (speculating; actual tests would be nice) immutable releases may not survive things like:

  • transfer of the repo to another org (or deletion)
  • recreate the repo at the old URL (e.g., forking back)

You also want to ensure things like zizmor is enforcing sanity in the CI recipe to avoid using the totallytrusted/butcompromised@v1 action (does GItHub log the commit ID of actions used anywhere for postmortem purposes?).

But that is all much in the much larger scope of how to derive a trust score for a given release (and all of that only verifies that an authenticated party did the action and followed the software process…not that they themselves have not injected malware).

1 Like

Purely from a "what would be nice UX" & without consideration of the ease of making the change, how about:

  1. adding a toggle to the docs.rs source code view: raw [o ] expanded, keeping the current behaviour by
    • defaulting to raw for "browse source"
    • defaulting to expanded for inline "source" links
  2. adding a note on the page that reads:
    • "this is the authoritative source which will be downloaded and built if you depend on this crate"
    • "this is the authoritative source which will be downloaded and built if you depend on this crate, as it would be expanded for a default set of configurations"[1]

  1. with a link to Metadata, or better the page which details the default cfg & links to this one ↩︎

That's definitely something that needs awareness measures. The suggested UX in my previous reply could be a good place to include more specifics on this topic. Also adding something like an (i) on crates.io & docs.rs to this effect where source, repo, etc. are rendered with a link to a more detailed explanation on crates.io would be very valuable.

It's clear from the discussions here that the base expectation is often - I get the code from github[1] - which is simply not the case and wouldn't be safe[2].

Yes, release immutability can help on github[1:1], but:

  • you need to actually browse the release tag, not main. Or download the source tarball, verify the checksum, unpack it locally, ...
  • this isn't what cargo does[3]
  • the current approach effectively provides immutable releases by pulling from crates.io
  • creating an immutable release for a lib is a chicken-and-egg problem as there are two steps, each of which could fail and which cannot be bundled into a single transaction[4]

  1. etc... ↩︎ ↩︎

  2. this is rust, not go ↩︎

  3. cargo binstall, however, ... at least has emerging support for only accepting immutable releases ↩︎

  4. I choose to do them in this order - example workflow here: MusicalNinjaDad/try_v2/.github/workflows/rust-publish.yml ↩︎

1 Like

How do the two views of the crate differ?

Wait what? What is an example of that? For the example you showed, both seem identical, or are they not? Certainly there are a lot of unexpanded macros in the rustdoc version of the code.

2 Likes

That's a half-truth.

  • Cross-linking and macro expansion exist, but neither of them are stabilized.
  • The main thing that rustdoc shows and docs.rs doesn't is build.rs-generated code.

The most readily observable difference in content is that rustdoc-generated source view contains only source files which the compiler read. This means that if you have #[cfg(not_this_platform)] mod foo;, foo.rs will not be included.

For example, in https://docs.rs/notify/8.2.0/src/notify/lib.rs.html#217, there is no file for the #[cfg(target_os = "windows")] pub mod windows;, because it was configured out in this particular rustdoc build, but in https://docs.rs/crate/notify/8.2.0/source/src/ (the docs.rs native source view), it is present (as other other non-Rust-source files like Cargo.toml).

As far as I know, the displayed text of the files that are present will always be the same, modulo build-time malicious code.

1 Like