[
  {
    "path": ".github/workflows/rust.yml",
    "content": "name: Rust CI\non:\n  push:\n    branches: [ master ]\n  pull_request:\n    branches: [ master ]\njobs:\n  build:\n    runs-on: ubuntu-latest\n    strategy:\n      matrix:\n        rust: [stable]\n    steps:\n    - uses: actions/checkout@v2\n    - run: rustup default ${{ matrix.rust }}\n    - name: build\n      run: >\n        cargo build --verbose\n    - name: test\n      run: >\n        cargo test --tests\n  rustfmt:\n    runs-on: ubuntu-latest\n    steps:\n    - uses: actions/checkout@v2\n    - uses: actions-rs/toolchain@v1\n      with:\n        toolchain: stable\n        override: true\n        components: rustfmt\n    - name: Run rustfmt check\n      uses: actions-rs/cargo@v1\n      with:\n        command: fmt\n        args: -- --check\n  doc:\n    runs-on: ubuntu-latest\n    strategy:\n      matrix:\n        rust: [stable]\n    steps:\n    - uses: actions/checkout@v2\n    - run: rustup default ${{ matrix.rust }}\n    - name: doc \n      run: >\n        cargo doc --no-deps --document-private-items --all-features\n"
  },
  {
    "path": ".gitignore",
    "content": "target\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "## v0.3.7 (2026-02-05)\n\n - Updated the caching code to handle the recent changes to crates.io dump format\n\n## v0.3.6 (2026-01-22)\n\n - Fixed the tool reporting transitive optional dependencies that are disabled by features as part of supply chain surface\n - Removed test JSON data from the git tree, matching the crates.io package to the git state again\n - Upgraded to cargo-metadata v0.23\n\n## v0.3.5 (2025-09-18)\n\n - Fixed support for Windows by switching from `xdg` crate to `dirs` crate for discovering the cache directory\n\n## v0.3.4 (2025-06-04)\n\n - Improved the message displayed when the latest data dump is considered outdated (contribution by @smoelius)\n - Bumped dependencies in Cargo.lock by running `cargo update`\n - Resolved some Clippy lints\n\n## v0.3.3 (2023-05-08)\n\n - Add `--no-dev` flag to omit dev dependencies (contribution by @smoelius)\n\n## v0.3.2 (2022-11-04)\n\n - Upgrade to `bpaf` 0.7\n\n## v0.3.1 (2021-03-18)\n\n - Fix `--features` flag not being honored if `--target` is also passed\n\n## v0.3.0 (2021-03-18)\n\n - Renamed `--cache_max_age` to `--cache-max-age` for consistency with Cargo flags\n - Accept flags such as `--target` directly, without relying on the escape hatch of passing cargo metadata arguments after `--`\n - No longer default to `--all-features`, handle features via the same flags as Cargo itself\n - The json schema is now printed separately, use `cargo supply-chain json --print-schema` to get it\n - Dropped the `help` subcommand. Use `--help` instead, e.g. `cargo supply-chain crates --help`\n\nInternal improvements:\n\n - Migrate to bpaf CLI parser, chosen for its balance of expressiveness vs complexity and supply chain sprawl\n - Add tests for the CLI interface\n - Do not regenerate the JSON schema on every build; saves a bit of build time and a bit of dependencies in production builds\n\n## v0.2.0 (2021-05-21)\n\n- Added `json` subcommand providing structured output and more details\n- Added `-d`, `--diffable` flag for diff-friendly output mode to all subcommands\n- Reduced the required download size for `update` subcommand from ~350Mb to ~60Mb\n- Added a detailed progress bar to all subcommands using `indicatif`\n- Fixed interrupted `update` subcommand considering its cache to be fresh.\n  Other subcommands were not affected and would simply fetch live data.\n- If a call to `cargo metadata` fails, show an error instead of panicking\n- The list of crates in the output of `publishers` subcommand is now sorted\n\n## v0.1.2 (2021-02-24)\n\n- Fix help text sometimes being misaligned\n- Change download progress messages to start counting from 1 rather than from 0\n- Only print warnings about crates.io that are immediately relevant to listing\n  dependencies and publishers\n\n## v0.1.1 (2021-02-18)\n\n- Drop extreaneous files from the tarball uploaded to crates.io\n\n## v0.1.0 (2021-02-18)\n\n- Drop `authors` subcommand\n- Add `help` subcommand providing detailed help for each subcommand\n- Bring help text more in line with Cargo help text\n- Warn about a large amount of data to be downloaded in `update` subcommand\n- Buffer reads and writes to cache files for a 6x speedup when using cache\n\n## v0.0.4 (2021-01-01)\n\n- Report failure instead of panicking on network failure in `update` subcommand\n- Correctly handle errors returned by the remote server\n\n## v0.0.3 (2020-12-28)\n\n- In case of network failure, retry with exponential backoff up to 3 times\n- Use local certificate store instead of bundling the trusted CA certificates\n- Refactor argument parsing to use `pico-args` instead of hand-rolled parser\n\n## v0.0.2 (2020-10-14)\n\n- `crates` - Shows the people or groups with publisher rights for each crate.\n- `publishers` - Is the reverse of `crates`, grouping by publisher instead.\n- `update` - Caches the data dumps from `crates.io` to avoid crawling the web\n  service when lookup up publisher and author information.\n\n## v0.0.1 (2020-10-02)\n\nInitial release, supports one command:\n- `authors` - Crawl through Cargo.toml of all crates and list their authors.\n  Authors might be listed multiple times. For each author, differentiate if\n  they are known by being mentioned in a crate from the local workspace or not.\n  Support for crawling `crates.io` sourced packages is planned.\n- `publishers` - Doesn't do anything right now.\n"
  },
  {
    "path": "Cargo.toml",
    "content": "[package]\nname = \"cargo-supply-chain\"\nversion = \"0.3.7\"\ndescription = \"Gather author, contributor, publisher data on crates in your dependency graph\"\nrepository = \"https://github.com/rust-secure-code/cargo-supply-chain\"\nauthors = [\"Andreas Molzer <andreas.molzer@gmx.de>\", \"Sergey \\\"Shnatsel\\\" Davidoff <shnatsel@gmail.com>\"]\nedition = \"2018\"\nlicense = \"Apache-2.0 OR MIT OR Zlib\"\ncategories = [\"development-tools::cargo-plugins\", \"command-line-utilities\"]\n\n[dependencies]\ncargo_metadata = \"0.23.0\"\ncsv = \"1.1\"\nflate2 = \"1\"\nhumantime = \"2\"\nhumantime-serde = \"1\"\nureq = { version = \"2.0.1\", default-features=false, features = [\"tls\", \"native-certs\", \"json\"] }\nserde = { version = \"1.0\", features = [\"derive\"] }\nserde_json = \"1.0\"\ntar = \"0.4.30\"\nindicatif = \"0.17.0\"\nbpaf = { version = \"0.9.1\", features = [\"derive\", \"dull-color\"] }\nanyhow = \"1.0.28\"\ndirs = \"6.0.0\"\n\n[dev-dependencies]\nschemars = \"0.8.3\"\n"
  },
  {
    "path": "LICENSE-APACHE",
    "content": "                              Apache License\n                        Version 2.0, January 2004\n                     http://www.apache.org/licenses/\n\nTERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n1. Definitions.\n\n   \"License\" shall mean the terms and conditions for use, reproduction,\n   and distribution as defined by Sections 1 through 9 of this document.\n\n   \"Licensor\" shall mean the copyright owner or entity authorized by\n   the copyright owner that is granting the License.\n\n   \"Legal Entity\" shall mean the union of the acting entity and all\n   other entities that control, are controlled by, or are under common\n   control with that entity. For the purposes of this definition,\n   \"control\" means (i) the power, direct or indirect, to cause the\n   direction or management of such entity, whether by contract or\n   otherwise, or (ii) ownership of fifty percent (50%) or more of the\n   outstanding shares, or (iii) beneficial ownership of such entity.\n\n   \"You\" (or \"Your\") shall mean an individual or Legal Entity\n   exercising permissions granted by this License.\n\n   \"Source\" form shall mean the preferred form for making modifications,\n   including but not limited to software source code, documentation\n   source, and configuration files.\n\n   \"Object\" form shall mean any form resulting from mechanical\n   transformation or translation of a Source form, including but\n   not limited to compiled object code, generated documentation,\n   and conversions to other media types.\n\n   \"Work\" shall mean the work of authorship, whether in Source or\n   Object form, made available under the License, as indicated by a\n   copyright notice that is included in or attached to the work\n   (an example is provided in the Appendix below).\n\n   \"Derivative Works\" shall mean any work, whether in Source or Object\n   form, that is based on (or derived from) the Work and for which the\n   editorial revisions, annotations, elaborations, or other modifications\n   represent, as a whole, an original work of authorship. For the purposes\n   of this License, Derivative Works shall not include works that remain\n   separable from, or merely link (or bind by name) to the interfaces of,\n   the Work and Derivative Works thereof.\n\n   \"Contribution\" shall mean any work of authorship, including\n   the original version of the Work and any modifications or additions\n   to that Work or Derivative Works thereof, that is intentionally\n   submitted to Licensor for inclusion in the Work by the copyright owner\n   or by an individual or Legal Entity authorized to submit on behalf of\n   the copyright owner. For the purposes of this definition, \"submitted\"\n   means any form of electronic, verbal, or written communication sent\n   to the Licensor or its representatives, including but not limited to\n   communication on electronic mailing lists, source code control systems,\n   and issue tracking systems that are managed by, or on behalf of, the\n   Licensor for the purpose of discussing and improving the Work, but\n   excluding communication that is conspicuously marked or otherwise\n   designated in writing by the copyright owner as \"Not a Contribution.\"\n\n   \"Contributor\" shall mean Licensor and any individual or Legal Entity\n   on behalf of whom a Contribution has been received by Licensor and\n   subsequently incorporated within the Work.\n\n2. Grant of Copyright License. Subject to the terms and conditions of\n   this License, each Contributor hereby grants to You a perpetual,\n   worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n   copyright license to reproduce, prepare Derivative Works of,\n   publicly display, publicly perform, sublicense, and distribute the\n   Work and such Derivative Works in Source or Object form.\n\n3. Grant of Patent License. Subject to the terms and conditions of\n   this License, each Contributor hereby grants to You a perpetual,\n   worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n   (except as stated in this section) patent license to make, have made,\n   use, offer to sell, sell, import, and otherwise transfer the Work,\n   where such license applies only to those patent claims licensable\n   by such Contributor that are necessarily infringed by their\n   Contribution(s) alone or by combination of their Contribution(s)\n   with the Work to which such Contribution(s) was submitted. If You\n   institute patent litigation against any entity (including a\n   cross-claim or counterclaim in a lawsuit) alleging that the Work\n   or a Contribution incorporated within the Work constitutes direct\n   or contributory patent infringement, then any patent licenses\n   granted to You under this License for that Work shall terminate\n   as of the date such litigation is filed.\n\n4. Redistribution. You may reproduce and distribute copies of the\n   Work or Derivative Works thereof in any medium, with or without\n   modifications, and in Source or Object form, provided that You\n   meet the following conditions:\n\n   (a) You must give any other recipients of the Work or\n       Derivative Works a copy of this License; and\n\n   (b) You must cause any modified files to carry prominent notices\n       stating that You changed the files; and\n\n   (c) You must retain, in the Source form of any Derivative Works\n       that You distribute, all copyright, patent, trademark, and\n       attribution notices from the Source form of the Work,\n       excluding those notices that do not pertain to any part of\n       the Derivative Works; and\n\n   (d) If the Work includes a \"NOTICE\" text file as part of its\n       distribution, then any Derivative Works that You distribute must\n       include a readable copy of the attribution notices contained\n       within such NOTICE file, excluding those notices that do not\n       pertain to any part of the Derivative Works, in at least one\n       of the following places: within a NOTICE text file distributed\n       as part of the Derivative Works; within the Source form or\n       documentation, if provided along with the Derivative Works; or,\n       within a display generated by the Derivative Works, if and\n       wherever such third-party notices normally appear. The contents\n       of the NOTICE file are for informational purposes only and\n       do not modify the License. You may add Your own attribution\n       notices within Derivative Works that You distribute, alongside\n       or as an addendum to the NOTICE text from the Work, provided\n       that such additional attribution notices cannot be construed\n       as modifying the License.\n\n   You may add Your own copyright statement to Your modifications and\n   may provide additional or different license terms and conditions\n   for use, reproduction, or distribution of Your modifications, or\n   for any such Derivative Works as a whole, provided Your use,\n   reproduction, and distribution of the Work otherwise complies with\n   the conditions stated in this License.\n\n5. Submission of Contributions. Unless You explicitly state otherwise,\n   any Contribution intentionally submitted for inclusion in the Work\n   by You to the Licensor shall be under the terms and conditions of\n   this License, without any additional terms or conditions.\n   Notwithstanding the above, nothing herein shall supersede or modify\n   the terms of any separate license agreement you may have executed\n   with Licensor regarding such Contributions.\n\n6. Trademarks. This License does not grant permission to use the trade\n   names, trademarks, service marks, or product names of the Licensor,\n   except as required for reasonable and customary use in describing the\n   origin of the Work and reproducing the content of the NOTICE file.\n\n7. Disclaimer of Warranty. Unless required by applicable law or\n   agreed to in writing, Licensor provides the Work (and each\n   Contributor provides its Contributions) on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n   implied, including, without limitation, any warranties or conditions\n   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n   PARTICULAR PURPOSE. You are solely responsible for determining the\n   appropriateness of using or redistributing the Work and assume any\n   risks associated with Your exercise of permissions under this License.\n\n8. Limitation of Liability. In no event and under no legal theory,\n   whether in tort (including negligence), contract, or otherwise,\n   unless required by applicable law (such as deliberate and grossly\n   negligent acts) or agreed to in writing, shall any Contributor be\n   liable to You for damages, including any direct, indirect, special,\n   incidental, or consequential damages of any character arising as a\n   result of this License or out of the use or inability to use the\n   Work (including but not limited to damages for loss of goodwill,\n   work stoppage, computer failure or malfunction, or any and all\n   other commercial damages or losses), even if such Contributor\n   has been advised of the possibility of such damages.\n\n9. Accepting Warranty or Additional Liability. While redistributing\n   the Work or Derivative Works thereof, You may choose to offer,\n   and charge a fee for, acceptance of support, warranty, indemnity,\n   or other liability obligations and/or rights consistent with this\n   License. However, in accepting such obligations, You may act only\n   on Your own behalf and on Your sole responsibility, not on behalf\n   of any other Contributor, and only if You agree to indemnify,\n   defend, and hold each Contributor harmless for any liability\n   incurred by, or claims asserted against, such Contributor by reason\n   of your accepting any such warranty or additional liability.\n\nEND OF TERMS AND CONDITIONS\n\nAPPENDIX: How to apply the Apache License to your work.\n\n   To apply the Apache License to your work, attach the following\n   boilerplate notice, with the fields enclosed by brackets \"[]\"\n   replaced with your own identifying information. (Don't include\n   the brackets!)  The text should be enclosed in the appropriate\n   comment syntax for the file format. We also recommend that a\n   file or class name and description of purpose be included on the\n   same \"printed page\" as the copyright notice for easier\n   identification within third-party archives.\n\nCopyright [yyyy] [name of copyright owner]\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n\thttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n"
  },
  {
    "path": "LICENSE-MIT",
    "content": "MIT License\n\nCopyright (c) 2020 Andreas Molzer aka. HeroicKatora\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "LICENSE-ZLIB",
    "content": "Copyright (c) 2020 Andreas Molzer aka. HeroicKatora\n\nThis software is provided 'as-is', without any express or implied warranty. In\nno event will the authors be held liable for any damages arising from the use\nof this software.\n\nPermission is granted to anyone to use this software for any purpose, including\ncommercial applications, and to alter it and redistribute it freely, subject to\nthe following restrictions:\n\n1. The origin of this software must not be misrepresented; you must not claim\n   that you wrote the original software. If you use this software in a product, an\n   acknowledgment in the product documentation would be appreciated but is not\n   required.\n\n2. Altered source versions must be plainly marked as such, and must not be\n   misrepresented as being the original software.\n\n3. This notice may not be removed or altered from any source distribution.\n"
  },
  {
    "path": "README.md",
    "content": "# cargo-supply-chain\n\nGather author, contributor and publisher data on crates in your dependency graph.\n\nUse cases include:\n\n- Find people and groups worth supporting.\n- Identify risks in your dependency graph.\n- An analysis of all the contributors you implicitly trust by building their software. This might have both a sobering and humbling effect.\n\nSample output when run on itself: [`publishers`](https://gist.github.com/Shnatsel/3b7f7d331d944bb75b2f363d4b5fb43d), [`crates`](https://gist.github.com/Shnatsel/dc0ec81f6ad392b8967e8d3f2b1f5f80), [`json`](https://gist.github.com/Shnatsel/511ad1f87528c450157ef9ad09984745).\n\n## Usage\n\nTo install this tool, please run the following command:\n\n```shell\ncargo install cargo-supply-chain\n```\n\nThen run it with:\n\n```shell\ncargo supply-chain publishers\n```\n\nBy default the supply chain is listed for **all targets** and **default features only**.\n\nYou can alter this behavior by passing `--target=…` to list dependencies for a specific target.\nYou can use `--all-features`, `--no-default-features`, and `--features=…` to control feature selection.\n\nHere's a list of subcommands:\n\n```none\nGather author, contributor and publisher data on crates in your dependency graph\n\nUsage: COMMAND [ARG]…\n\nAvailable options:\n    -h, --help      Prints help information\n    -v, --version   Prints version information\n\nAvailable commands:\n    publishers  List all crates.io publishers in the depedency graph\n    crates      List all crates in dependency graph and crates.io publishers for each\n    json        Like 'crates', but in JSON and with more fields for each publisher\n    update      Download the latest daily dump from crates.io to speed up other commands\n\nMost commands also accept flags controlling the features, targets, etc.\nSee 'cargo supply-chain <command> --help' for more information on a specific command.\n```\n\n## License\n\nTriple licensed under any of Apache-2.0, MIT, or zlib terms.\n"
  },
  {
    "path": "fixtures/optional_non_dev_dep/Cargo.toml",
    "content": "[package]\nname = \"optional_non_dev_dep\"\nversion = \"0.1.0\"\nedition = \"2024\"\npublish = false\n\n[dependencies]\nlibz-rs-sys = { version = \"=0.5.5\", optional = true }\n\n[dev-dependencies]\nlibz-rs-sys = \"=0.5.5\"\n\n[workspace]\n"
  },
  {
    "path": "fixtures/optional_non_dev_dep/src/lib.rs",
    "content": "pub fn add(left: u64, right: u64) -> u64 {\n    left + right\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn it_works() {\n        let result = add(2, 2);\n        assert_eq!(result, 4);\n    }\n}\n"
  },
  {
    "path": "src/api_client.rs",
    "content": "use std::time::{Duration, Instant};\n\npub struct RateLimitedClient {\n    last_request_time: Option<Instant>,\n    agent: ureq::Agent,\n}\n\nimpl Default for RateLimitedClient {\n    fn default() -> Self {\n        RateLimitedClient {\n            last_request_time: None,\n            agent: ureq::agent(),\n        }\n    }\n}\n\nimpl RateLimitedClient {\n    pub fn new() -> Self {\n        RateLimitedClient::default()\n    }\n\n    pub fn get(&mut self, url: &str) -> ureq::Request {\n        self.wait_to_honor_rate_limit();\n        self.agent.get(url).set(\n            \"User-Agent\",\n            \"cargo supply-chain (https://github.com/rust-secure-code/cargo-supply-chain)\",\n        )\n    }\n\n    /// Waits until at least 1 second has elapsed since last request,\n    /// as per <https://crates.io/data-access>\n    fn wait_to_honor_rate_limit(&mut self) {\n        if let Some(prev_req_time) = self.last_request_time {\n            let next_req_time = prev_req_time + Duration::from_secs(1);\n            if let Some(time_to_wait) = next_req_time.checked_duration_since(Instant::now()) {\n                std::thread::sleep(time_to_wait);\n            }\n        }\n        self.last_request_time = Some(Instant::now());\n    }\n}\n"
  },
  {
    "path": "src/cli.rs",
    "content": "use bpaf::*;\nuse std::{path::PathBuf, time::Duration};\n\n/// Arguments to be passed to `cargo metadata`\n#[derive(Clone, Debug, Bpaf)]\n#[bpaf(generate(meta_args))]\npub struct MetadataArgs {\n    // `all_features` and `no_default_features` are not mutually exclusive in `cargo metadata`,\n    // in the sense that it will not error out when encountering them; it just follows `all_features`\n    /// Activate all available features\n    pub all_features: bool,\n\n    /// Do not activate the `default` feature\n    pub no_default_features: bool,\n\n    /// Ignore dev-only dependencies\n    pub no_dev: bool,\n\n    // This is a `String` because we don't parse the value, just pass it on to `cargo metadata` blindly\n    /// Space or comma separated list of features to activate\n    #[bpaf(argument(\"FEATURES\"))]\n    pub features: Option<String>,\n\n    /// Only include dependencies matching the given target-triple\n    #[bpaf(argument(\"TRIPLE\"))]\n    pub target: Option<String>,\n\n    /// Path to Cargo.toml\n    #[bpaf(argument(\"PATH\"))]\n    pub manifest_path: Option<PathBuf>,\n}\n\n/// Arguments for typical querying commands - crates, publishers, json\n#[derive(Clone, Debug, Bpaf)]\n#[bpaf(generate(args))]\npub(crate) struct QueryCommandArgs {\n    #[bpaf(external)]\n    pub cache_max_age: Duration,\n\n    /// Make output more friendly towards tools such as `diff`\n    #[bpaf(short, long)]\n    pub diffable: bool,\n}\n\n#[derive(Clone, Debug, Bpaf)]\npub(crate) enum PrintJson {\n    /// Print JSON schema and exit\n    #[bpaf(long(\"print-schema\"))]\n    Schema,\n\n    Info {\n        #[bpaf(external)]\n        args: QueryCommandArgs,\n        #[bpaf(external)]\n        meta_args: MetadataArgs,\n    },\n}\n\n/// Gather author, contributor and publisher data on crates in your dependency graph\n///\n///\n/// Most commands also accept flags controlling the features, targets, etc.\n///  See 'cargo supply-chain <command> --help' for more information on a specific command.\n#[derive(Clone, Debug, Bpaf)]\n#[bpaf(options(\"supply-chain\"), generate(args_parser), version)]\npub(crate) enum CliArgs {\n    /// Lists all crates.io publishers in the dependency graph and owned crates for each\n    ///\n    ///\n    /// If a local cache created by 'update' subcommand is present and up to date,\n    /// it will be used. Otherwise live data will be fetched from the crates.io API.\n    #[bpaf(command)]\n    Publishers {\n        #[bpaf(external)]\n        args: QueryCommandArgs,\n        #[bpaf(external)]\n        meta_args: MetadataArgs,\n    },\n\n    /// List all crates in dependency graph and crates.io publishers for each\n    ///\n    ///\n    /// If a local cache created by 'update' subcommand is present and up to date,\n    /// it will be used. Otherwise live data will be fetched from the crates.io API.\n    #[bpaf(command)]\n    Crates {\n        #[bpaf(external)]\n        args: QueryCommandArgs,\n        #[bpaf(external)]\n        meta_args: MetadataArgs,\n    },\n\n    /// Detailed info on publishers of all crates in the dependency graph, in JSON\n    ///\n    /// The JSON schema is also available, use --print-schema to get it.\n    ///\n    /// If a local cache created by 'update' subcommand is present and up to date,\n    /// it will be used. Otherwise live data will be fetched from the crates.io API.\",\n    #[bpaf(command)]\n    Json(#[bpaf(external(print_json))] PrintJson),\n\n    /// Download the latest daily dump from crates.io to speed up other commands\n    ///\n    ///\n    /// If the local cache is already younger than specified in '--cache-max-age' option,\n    /// a newer version will not be downloaded.\n    ///\n    /// Note that this downloads the entire crates.io database, which is hundreds of Mb of data!\n    /// If you are on a metered connection, you should not be running the 'update' subcommand.\n    /// Instead, rely on requests to the live API - they are slower, but use much less data.\n    #[bpaf(command)]\n    Update {\n        #[bpaf(external)]\n        cache_max_age: Duration,\n    },\n}\n\nfn cache_max_age() -> impl Parser<Duration> {\n    long(\"cache-max-age\")\n        .help(\n            \"\\\nThe cache will be considered valid while younger than specified.\nThe format is a human readable duration such as `1w` or `1d 6h`.\nIf not specified, the cache is considered valid for 48 hours.\",\n        )\n        .argument::<String>(\"AGE\")\n        .parse(|text| humantime::parse_duration(&text))\n        .fallback(Duration::from_secs(48 * 3600))\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    fn parse_args(args: &[&str]) -> Result<CliArgs, ParseFailure> {\n        args_parser().run_inner(Args::from(args))\n    }\n\n    #[test]\n    fn test_cache_max_age_parser() {\n        let _ = parse_args(&[\"crates\", \"--cache-max-age\", \"7d\"]).unwrap();\n        let _ = parse_args(&[\"crates\", \"--cache-max-age=7d\"]).unwrap();\n        let _ = parse_args(&[\"crates\", \"--cache-max-age=1w\"]).unwrap();\n        let _ = parse_args(&[\"crates\", \"--cache-max-age=1m\"]).unwrap();\n        let _ = parse_args(&[\"crates\", \"--cache-max-age=1s\"]).unwrap();\n        // erroneous invocations that must be rejected\n        assert!(parse_args(&[\"crates\", \"--cache-max-age\"]).is_err());\n        assert!(parse_args(&[\"crates\", \"--cache-max-age=5\"]).is_err());\n    }\n\n    #[test]\n    fn test_accepted_query_options() {\n        for command in [\"crates\", \"publishers\", \"json\"] {\n            let _ = args_parser().run_inner(&[command][..]).unwrap();\n            let _ = args_parser().run_inner(&[command, \"-d\"][..]).unwrap();\n            let _ = args_parser()\n                .run_inner(&[command, \"--diffable\"][..])\n                .unwrap();\n            let _ = args_parser()\n                .run_inner(&[command, \"--cache-max-age=7d\"][..])\n                .unwrap();\n            let _ = args_parser()\n                .run_inner(&[command, \"-d\", \"--cache-max-age=7d\"][..])\n                .unwrap();\n            let _ = args_parser()\n                .run_inner(&[command, \"--diffable\", \"--cache-max-age=7d\"][..])\n                .unwrap();\n        }\n    }\n\n    #[test]\n    fn test_accepted_update_options() {\n        let _ = args_parser().run_inner(Args::from(&[\"update\"])).unwrap();\n        let _ = parse_args(&[\"update\", \"--cache-max-age=7d\"]).unwrap();\n        // erroneous invocations that must be rejected\n        assert!(parse_args(&[\"update\", \"-d\"]).is_err());\n        assert!(parse_args(&[\"update\", \"--diffable\"]).is_err());\n        assert!(parse_args(&[\"update\", \"-d\", \"--cache-max-age=7d\"]).is_err());\n        assert!(parse_args(&[\"update\", \"--diffable\", \"--cache-max-age=7d\"]).is_err());\n    }\n\n    #[test]\n    fn test_json_schema_option() {\n        let _ = parse_args(&[\"json\", \"--print-schema\"]).unwrap();\n        // erroneous invocations that must be rejected\n        assert!(parse_args(&[\"json\", \"--print-schema\", \"-d\"]).is_err());\n        assert!(parse_args(&[\"json\", \"--print-schema\", \"--diffable\"]).is_err());\n        assert!(parse_args(&[\"json\", \"--print-schema\", \"--cache-max-age=7d\"]).is_err());\n        assert!(\n            parse_args(&[\"json\", \"--print-schema\", \"--diffable\", \"--cache-max-age=7d\"]).is_err()\n        );\n    }\n\n    #[test]\n    fn test_invocation_through_cargo() {\n        let _ = parse_args(&[\"supply-chain\", \"update\"]).unwrap();\n        let _ = parse_args(&[\"supply-chain\", \"publishers\", \"-d\"]).unwrap();\n        let _ = parse_args(&[\"supply-chain\", \"crates\", \"-d\", \"--cache-max-age=5h\"]).unwrap();\n        let _ = parse_args(&[\"supply-chain\", \"json\", \"--diffable\"]).unwrap();\n        let _ = parse_args(&[\"supply-chain\", \"json\", \"--print-schema\"]).unwrap();\n        // erroneous invocations to be rejected\n        assert!(parse_args(&[\"supply-chain\", \"supply-chain\", \"json\", \"--print-schema\"]).is_err());\n        assert!(parse_args(&[\"supply-chain\", \"supply-chain\", \"crates\", \"-d\"]).is_err());\n    }\n}\n"
  },
  {
    "path": "src/common.rs",
    "content": "use anyhow::bail;\nuse cargo_metadata::{\n    CargoOpt::AllFeatures, CargoOpt::NoDefaultFeatures, DependencyKind, Metadata, MetadataCommand,\n    NodeDep, Package, PackageId,\n};\nuse std::collections::{HashMap, HashSet};\n\npub use crate::cli::MetadataArgs;\n\n#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]\n#[cfg_attr(test, derive(serde::Deserialize, serde::Serialize))]\npub enum PkgSource {\n    Local,\n    CratesIo,\n    Foreign,\n}\n\n#[derive(Debug, Clone)]\n#[cfg_attr(test, derive(Eq, PartialEq, serde::Deserialize, serde::Serialize))]\npub struct SourcedPackage {\n    pub source: PkgSource,\n    pub package: Package,\n}\n\nfn metadata_command(args: MetadataArgs) -> MetadataCommand {\n    let mut command = MetadataCommand::new();\n    if args.all_features {\n        command.features(AllFeatures);\n    }\n    if args.no_default_features {\n        command.features(NoDefaultFeatures);\n    }\n    if let Some(path) = args.manifest_path {\n        command.manifest_path(path);\n    }\n    let mut other_options = Vec::new();\n    if let Some(target) = args.target {\n        other_options.push(format!(\"--filter-platform={}\", target));\n    }\n    // `cargo-metadata` crate assumes we have a Vec of features,\n    // but we really didn't want to parse it ourselves, so we pass the argument directly\n    if let Some(features) = args.features {\n        other_options.push(format!(\"--features={}\", features));\n    }\n    command.other_options(other_options);\n    command\n}\n\npub fn sourced_dependencies(\n    metadata_args: MetadataArgs,\n) -> Result<Vec<SourcedPackage>, anyhow::Error> {\n    let no_dev = metadata_args.no_dev;\n    let command = metadata_command(metadata_args);\n    let meta = match command.exec() {\n        Ok(v) => v,\n        Err(cargo_metadata::Error::CargoMetadata { stderr: e }) => bail!(e),\n        Err(err) => bail!(\"Failed to fetch crate metadata!\\n  {}\", err),\n    };\n\n    sourced_dependencies_from_metadata(meta, no_dev)\n}\n\nfn sourced_dependencies_from_metadata(\n    meta: Metadata,\n    no_dev: bool,\n) -> Result<Vec<SourcedPackage>, anyhow::Error> {\n    let mut how: HashMap<PackageId, PkgSource> = HashMap::new();\n    let mut what: HashMap<PackageId, Package> = meta\n        .packages\n        .iter()\n        .map(|package| (package.id.clone(), package.clone()))\n        .collect();\n\n    for pkg in &meta.packages {\n        // Suppose every package is foreign, until proven otherwise..\n        how.insert(pkg.id.clone(), PkgSource::Foreign);\n    }\n\n    // Find the crates.io dependencies..\n    for pkg in &meta.packages {\n        if let Some(source) = pkg.source.as_ref() {\n            if source.is_crates_io() {\n                how.insert(pkg.id.clone(), PkgSource::CratesIo);\n            }\n        }\n    }\n\n    for pkg in &meta.workspace_members {\n        *how.get_mut(pkg).unwrap() = PkgSource::Local;\n    }\n\n    if no_dev {\n        (how, what) = extract_non_dev_dependencies(&meta, &mut how, &mut what);\n    }\n\n    let dependencies: Vec<_> = how\n        .iter()\n        .map(|(id, kind)| {\n            let dep = what.get(id).cloned().unwrap();\n            SourcedPackage {\n                source: *kind,\n                package: dep,\n            }\n        })\n        .collect();\n\n    Ok(dependencies)\n}\n\n/// Start with the `PkgSource::Local` packages, then iteratively add non-dev-dependencies until no\n/// more packages can be added, and return the results.\n///\n/// This function uses the resolved dependency graph from `cargo metadata` to determine which\n/// dependencies are actually used. This function does _not_ use the declared dependencies, which\n/// may include optional dependencies that aren't actually used.\nfn extract_non_dev_dependencies(\n    meta: &Metadata,\n    how: &mut HashMap<PackageId, PkgSource>,\n    what: &mut HashMap<PackageId, Package>,\n) -> (HashMap<PackageId, PkgSource>, HashMap<PackageId, Package>) {\n    let mut how_new = HashMap::new();\n    let mut what_new = HashMap::new();\n\n    let Some(resolve) = &meta.resolve else {\n        return (HashMap::new(), HashMap::new());\n    };\n\n    let node_deps: HashMap<&PackageId, &[NodeDep]> = resolve\n        .nodes\n        .iter()\n        .map(|node| (&node.id, node.deps.as_slice()))\n        .collect();\n\n    let mut ids = how\n        .iter()\n        .filter_map(|(id, source)| {\n            if matches!(source, PkgSource::Local) {\n                Some(id.clone())\n            } else {\n                None\n            }\n        })\n        .collect::<Vec<_>>();\n\n    while !ids.is_empty() {\n        let mut deps = HashSet::new();\n\n        for id in ids.drain(..) {\n            if let Some(node_deps) = node_deps.get(&id) {\n                for dep in *node_deps {\n                    if dep\n                        .dep_kinds\n                        .iter()\n                        .any(|info| info.kind != DependencyKind::Development)\n                    {\n                        deps.insert(&dep.pkg);\n                    }\n                }\n            }\n\n            how_new.insert(id.clone(), how.remove(&id).unwrap());\n            what_new.insert(id.clone(), what.remove(&id).unwrap());\n        }\n\n        for pkg_id in what.keys() {\n            if deps.contains(pkg_id) {\n                ids.push(pkg_id.clone());\n            }\n        }\n    }\n\n    (how_new, what_new)\n}\n\npub fn crate_names_from_source(crates: &[SourcedPackage], source: PkgSource) -> Vec<String> {\n    let mut filtered_crate_names: Vec<String> = crates\n        .iter()\n        .filter(|p| p.source == source)\n        .map(|p| p.package.name.to_string())\n        .collect();\n    // Collecting into a HashSet is less user-friendly because order varies between runs\n    filtered_crate_names.sort_unstable();\n    filtered_crate_names.dedup();\n    filtered_crate_names\n}\n\npub fn complain_about_non_crates_io_crates(dependencies: &[SourcedPackage]) {\n    {\n        // scope bound to avoid accidentally referencing local crates when working with foreign ones\n        let local_crate_names = crate_names_from_source(dependencies, PkgSource::Local);\n        if !local_crate_names.is_empty() {\n            eprintln!(\n                \"\\nThe following crates will be ignored because they come from a local directory:\"\n            );\n            for crate_name in &local_crate_names {\n                eprintln!(\" - {}\", crate_name);\n            }\n        }\n    }\n\n    {\n        let foreign_crate_names = crate_names_from_source(dependencies, PkgSource::Foreign);\n        if !foreign_crate_names.is_empty() {\n            eprintln!(\"\\nCannot audit the following crates because they are not from crates.io:\");\n            for crate_name in &foreign_crate_names {\n                eprintln!(\" - {}\", crate_name);\n            }\n        }\n    }\n}\n\npub fn comma_separated_list(list: &[String]) -> String {\n    let mut result = String::new();\n    let mut first_loop = true;\n    for crate_name in list {\n        if !first_loop {\n            result.push_str(\", \");\n        }\n        first_loop = false;\n        result.push_str(crate_name.as_str());\n    }\n    result\n}\n\n#[cfg(test)]\nmod tests {\n    use super::sourced_dependencies_from_metadata;\n    use cargo_metadata::MetadataCommand;\n    #[test]\n    fn optional_dependency_excluded_when_not_activated() {\n        let metadata = MetadataCommand::new()\n            .current_dir(\"fixtures/optional_non_dev_dep\")\n            .exec()\n            .unwrap();\n\n        let deps = sourced_dependencies_from_metadata(metadata.clone(), false).unwrap();\n        assert!(deps.iter().any(|dep| dep.package.name == \"libz-rs-sys\"));\n\n        let deps_no_dev = sourced_dependencies_from_metadata(metadata, true).unwrap();\n        assert!(!deps_no_dev\n            .iter()\n            .any(|dep| dep.package.name == \"libz-rs-sys\"));\n    }\n}\n"
  },
  {
    "path": "src/crates_cache.rs",
    "content": "use crate::api_client::RateLimitedClient;\nuse crate::publishers::{PublisherData, PublisherKind};\nuse dirs;\nuse flate2::read::GzDecoder;\nuse serde::{Deserialize, Serialize};\nuse std::{\n    collections::{BTreeSet, HashMap},\n    fs,\n    io::{self, ErrorKind},\n    path::PathBuf,\n    time::Duration,\n    time::SystemTimeError,\n};\n\npub struct CratesCache {\n    cache_dir: Option<CacheDir>,\n    metadata: Option<MetadataStored>,\n    crates: Option<HashMap<String, Crate>>,\n    crate_owners: Option<HashMap<u64, Vec<CrateOwner>>>,\n    users: Option<HashMap<u64, User>>,\n    teams: Option<HashMap<u64, Team>>,\n    versions: Option<HashMap<(u64, String), Publisher>>,\n}\n\npub enum CacheState {\n    Fresh,\n    Expired,\n    Unknown,\n}\n\npub enum DownloadState {\n    /// The tag still matched and resource was not stale.\n    Fresh,\n    /// There was a newer resource.\n    Expired,\n    /// We forced the download of an update.\n    Stale,\n}\n\nstruct CacheDir(PathBuf);\n\n#[derive(Clone, Deserialize, Serialize)]\nstruct Metadata {\n    #[serde(with = \"humantime_serde\")]\n    timestamp: std::time::SystemTime,\n}\n\n#[derive(Clone, Deserialize, Serialize)]\nstruct MetadataStored {\n    #[serde(with = \"humantime_serde\")]\n    timestamp: std::time::SystemTime,\n    #[serde(default)]\n    etag: Option<String>,\n}\n\n#[derive(Clone, Deserialize, Serialize)]\nstruct Crate {\n    name: String,\n    id: u64,\n    repository: Option<String>,\n}\n\n#[derive(Clone, Deserialize, Serialize)]\nstruct CrateOwner {\n    crate_id: u64,\n    owner_id: u64,\n    owner_kind: i32,\n}\n\n#[derive(Clone, Deserialize, Serialize)]\nstruct Publisher {\n    crate_id: u64,\n    published_by: u64,\n}\n\n#[derive(Clone, Deserialize, Serialize)]\nstruct Team {\n    id: u64,\n    avatar: Option<String>,\n    login: String,\n    name: Option<String>,\n}\n\n#[derive(Clone, Deserialize, Serialize)]\nstruct User {\n    id: u64,\n    gh_avatar: Option<String>,\n    gh_id: Option<String>,\n    gh_login: String,\n    name: Option<String>,\n}\n\nimpl CratesCache {\n    const METADATA_FS: &'static str = \"metadata.json\";\n    const CRATES_FS: &'static str = \"crates.json\";\n    const CRATE_OWNERS_FS: &'static str = \"crate_owners.json\";\n    const USERS_FS: &'static str = \"users.json\";\n    const TEAMS_FS: &'static str = \"teams.json\";\n    const VERSIONS_FS: &'static str = \"versions.json\";\n\n    const DUMP_URL: &'static str = \"https://static.crates.io/db-dump.tar.gz\";\n\n    /// Open a crates cache.\n    pub fn new() -> Self {\n        CratesCache {\n            cache_dir: Self::cache_dir().map(CacheDir),\n            metadata: None,\n            crates: None,\n            crate_owners: None,\n            users: None,\n            teams: None,\n            versions: None,\n        }\n    }\n\n    fn cache_dir() -> Option<PathBuf> {\n        dirs::cache_dir()\n    }\n\n    /// Re-download the list from the data dumps.\n    pub fn download(\n        &mut self,\n        client: &mut RateLimitedClient,\n        max_age: Duration,\n    ) -> Result<DownloadState, io::Error> {\n        let bar = indicatif::ProgressBar::new(!0)\n            .with_prefix(\"Downloading\")\n            .with_style(\n                indicatif::ProgressStyle::default_spinner()\n                    .template(\"{prefix:>12.bright.cyan} {spinner} {msg:.cyan}\")\n                    .unwrap(),\n            )\n            .with_message(\"preparing\");\n\n        let remembered_etag;\n        let response = {\n            let mut request = client.get(Self::DUMP_URL);\n            if let Some(meta) = self.load_metadata() {\n                remembered_etag = meta.etag.clone();\n                // See if we can consider the resource not-yet-stale.\n                if meta.validate(max_age) == Some(true) {\n                    if let Some(etag) = meta.etag.as_ref() {\n                        request = request.set(\"if-none-match\", etag);\n                    }\n                }\n            } else {\n                remembered_etag = None;\n            }\n            request.call()\n        }\n        .map_err(io::Error::other)?;\n\n        // Not modified.\n        if response.status() == 304 {\n            bar.finish_and_clear();\n            return Ok(DownloadState::Fresh);\n        }\n\n        if let Some(length) = response\n            .header(\"content-length\")\n            .and_then(|l| l.parse().ok())\n        {\n            bar.set_style(\n                indicatif::ProgressStyle::default_bar()\n                    .template(\"{prefix:>12.bright.cyan} [{bar:27}] {bytes:>9}/{total_bytes:9}  {bytes_per_sec}  ETA {eta:4} - {msg:.cyan}\").unwrap()\n                    .progress_chars(\"=> \"));\n            bar.set_length(length);\n        } else {\n            bar.println(\"Length unspecified, expect at least 250MiB\");\n            bar.set_style(indicatif::ProgressStyle::default_spinner().template(\n                \"{prefix:>12.bright.cyan} {spinner} {bytes:>9} {bytes_per_sec} - {msg:.cyan}\",\n            ).unwrap());\n        }\n\n        let etag = response.header(\"etag\").map(String::from);\n        let reader = bar.wrap_read(response.into_reader());\n        let ungzip = GzDecoder::new(reader);\n        let mut archive = tar::Archive::new(ungzip);\n\n        let cache_dir = CratesCache::cache_dir().ok_or(ErrorKind::NotFound)?;\n        let mut cache_updater = CacheUpdater::new(cache_dir)?;\n        let required_files = [\n            Self::CRATE_OWNERS_FS,\n            Self::CRATES_FS,\n            Self::USERS_FS,\n            Self::TEAMS_FS,\n            Self::METADATA_FS,\n        ]\n        .iter()\n        .map(ToString::to_string)\n        .collect::<BTreeSet<_>>();\n\n        for entry in (archive.entries()?).flatten() {\n            if let Ok(path) = entry.path() {\n                if let Some(name) = path.file_name().and_then(std::ffi::OsStr::to_str) {\n                    bar.set_message(name.to_string());\n                }\n            }\n            if entry_path_ends_with(&entry, \"crate_owners.csv\") {\n                let owners: Vec<CrateOwner> = read_csv_data(entry)?;\n                cache_updater.store_multi_map(\n                    &mut self.crate_owners,\n                    Self::CRATE_OWNERS_FS,\n                    owners.as_slice(),\n                    &|owner| owner.crate_id,\n                )?;\n            } else if entry_path_ends_with(&entry, \"crates.csv\") {\n                let crates: Vec<Crate> = read_csv_data(entry)?;\n                cache_updater.store_map(\n                    &mut self.crates,\n                    Self::CRATES_FS,\n                    crates.as_slice(),\n                    &|crate_| crate_.name.clone(),\n                )?;\n            } else if entry_path_ends_with(&entry, \"users.csv\") {\n                let users: Vec<User> = read_csv_data(entry)?;\n                cache_updater.store_map(\n                    &mut self.users,\n                    Self::USERS_FS,\n                    users.as_slice(),\n                    &|user| user.id,\n                )?;\n            } else if entry_path_ends_with(&entry, \"teams.csv\") {\n                let teams: Vec<Team> = read_csv_data(entry)?;\n                cache_updater.store_map(\n                    &mut self.teams,\n                    Self::TEAMS_FS,\n                    teams.as_slice(),\n                    &|team| team.id,\n                )?;\n            } else if entry_path_ends_with(&entry, \"metadata.json\") {\n                let meta: Metadata = serde_json::from_reader(entry)?;\n                cache_updater.store(\n                    &mut self.metadata,\n                    Self::METADATA_FS,\n                    MetadataStored {\n                        timestamp: meta.timestamp,\n                        etag: etag.clone(),\n                    },\n                )?;\n            } else {\n                // This was not a file with a filename we actually use.\n                // Check if we've obtained all the files we need.\n                // If yes, we can end the download early.\n                // This saves hundreds of megabytes of traffic.\n                if required_files.is_subset(&cache_updater.staged_files) {\n                    break;\n                }\n            }\n        }\n        // Now that we've successfully downloaded and stored everything,\n        // replace the old cache contents with the new one.\n        cache_updater.commit()?;\n\n        // If we get here, we had no etag or the etag mismatched or we forced a download due to\n        // stale data. Catch the last as it means the crates.io daily dumps were not updated.\n        if remembered_etag == etag {\n            Ok(DownloadState::Stale)\n        } else {\n            Ok(DownloadState::Expired)\n        }\n    }\n\n    pub fn expire(&mut self, max_age: Duration) -> CacheState {\n        match self.validate(max_age) {\n            // Still fresh.\n            Some(true) => CacheState::Fresh,\n            // There was no valid meta data. Consider expired for safety.\n            None => {\n                self.cache_dir = None;\n                CacheState::Unknown\n            }\n            Some(false) => {\n                self.cache_dir = None;\n                CacheState::Expired\n            }\n        }\n    }\n\n    pub fn age(&mut self) -> Option<Duration> {\n        match self.load_metadata() {\n            Some(meta) => meta.age().ok(),\n            None => None,\n        }\n    }\n\n    pub fn publisher_users(&mut self, crate_name: &str) -> Option<Vec<PublisherData>> {\n        let id = self.load_crates()?.get(crate_name)?.id;\n        let owners = self.load_crate_owners()?.get(&id)?.clone();\n        let users = self.load_users()?;\n        let publisher = owners\n            .into_iter()\n            .filter(|owner| owner.owner_kind == 0)\n            .filter_map(|owner: CrateOwner| {\n                let user = users.get(&owner.owner_id)?;\n                Some(PublisherData {\n                    id: user.id,\n                    avatar: user.gh_avatar.clone(),\n                    login: user.gh_login.clone(),\n                    name: user.name.clone(),\n                    kind: PublisherKind::user,\n                })\n            })\n            .collect();\n        Some(publisher)\n    }\n\n    pub fn publisher_teams(&mut self, crate_name: &str) -> Option<Vec<PublisherData>> {\n        let id = self.load_crates()?.get(crate_name)?.id;\n        let owners = self.load_crate_owners()?.get(&id)?.clone();\n        let teams = self.load_teams()?;\n        let publisher = owners\n            .into_iter()\n            .filter(|owner| owner.owner_kind == 1)\n            .filter_map(|owner: CrateOwner| {\n                let team = teams.get(&owner.owner_id)?;\n                Some(PublisherData {\n                    id: team.id,\n                    avatar: team.avatar.clone(),\n                    login: team.login.clone(),\n                    name: team.name.clone(),\n                    kind: PublisherKind::team,\n                })\n            })\n            .collect();\n        Some(publisher)\n    }\n\n    fn validate(&mut self, max_age: Duration) -> Option<bool> {\n        let meta = self.load_metadata()?;\n        meta.validate(max_age)\n    }\n\n    fn load_metadata(&mut self) -> Option<&MetadataStored> {\n        self.cache_dir\n            .as_ref()?\n            .load_cached(&mut self.metadata, Self::METADATA_FS)\n            .ok()\n    }\n\n    fn load_crates(&mut self) -> Option<&HashMap<String, Crate>> {\n        self.cache_dir\n            .as_ref()?\n            .load_cached(&mut self.crates, Self::CRATES_FS)\n            .ok()\n    }\n\n    fn load_crate_owners(&mut self) -> Option<&HashMap<u64, Vec<CrateOwner>>> {\n        self.cache_dir\n            .as_ref()?\n            .load_cached(&mut self.crate_owners, Self::CRATE_OWNERS_FS)\n            .ok()\n    }\n\n    fn load_users(&mut self) -> Option<&HashMap<u64, User>> {\n        self.cache_dir\n            .as_ref()?\n            .load_cached(&mut self.users, Self::USERS_FS)\n            .ok()\n    }\n\n    fn load_teams(&mut self) -> Option<&HashMap<u64, Team>> {\n        self.cache_dir\n            .as_ref()?\n            .load_cached(&mut self.teams, Self::TEAMS_FS)\n            .ok()\n    }\n\n    fn load_versions(&mut self) -> Option<&HashMap<(u64, String), Publisher>> {\n        self.cache_dir\n            .as_ref()?\n            .load_cached(&mut self.versions, Self::VERSIONS_FS)\n            .ok()\n    }\n}\n\nfn entry_path_ends_with<R: io::Read>(entry: &tar::Entry<R>, needle: &str) -> bool {\n    let Ok(path) = entry.path() else {\n        return false;\n    };\n    let Some(file_name) = path.file_name() else {\n        return false;\n    };\n    file_name == needle\n}\n\nfn read_csv_data<T: serde::de::DeserializeOwned>(\n    from: impl io::Read,\n) -> Result<Vec<T>, csv::Error> {\n    let mut reader = csv::ReaderBuilder::new()\n        .delimiter(b',')\n        .double_quote(true)\n        .quoting(true)\n        .from_reader(from);\n    reader.deserialize().collect()\n}\n\nimpl MetadataStored {\n    fn validate(&self, max_age: Duration) -> Option<bool> {\n        match self.age() {\n            Ok(duration) => Some(duration < max_age),\n            Err(_) => None,\n        }\n    }\n\n    pub fn age(&self) -> Result<Duration, SystemTimeError> {\n        self.timestamp.elapsed()\n    }\n}\n\nimpl CacheDir {\n    fn load_cached<'cache, T>(\n        &self,\n        cache: &'cache mut Option<T>,\n        file: &str,\n    ) -> Result<&'cache T, io::Error>\n    where\n        T: serde::de::DeserializeOwned,\n    {\n        match cache {\n            Some(datum) => Ok(datum),\n            None => {\n                let file = fs::File::open(self.0.join(file))?;\n                let reader = io::BufReader::new(file);\n                let crates: T = serde_json::from_reader(reader).unwrap();\n                Ok(cache.get_or_insert(crates))\n            }\n        }\n    }\n}\n\n/// Implements a two-phase transactional update mechanism:\n/// you can store data, but it will not overwrite previous data until you call `commit()`\nstruct CacheUpdater {\n    dir: PathBuf,\n    staged_files: BTreeSet<String>,\n}\n\n/// Creates the cache directory if it doesn't exist.\n/// Returns an error if creation fails.\nimpl CacheUpdater {\n    fn new(dir: PathBuf) -> Result<Self, io::Error> {\n        if !dir.exists() {\n            fs::create_dir_all(&dir)?;\n        }\n\n        if !dir.is_dir() {\n            // Well. We certainly don't want to delete anything.\n            return Err(io::ErrorKind::AlreadyExists.into());\n        }\n\n        Ok(Self {\n            dir,\n            staged_files: BTreeSet::new(),\n        })\n    }\n\n    /// Commits to disk any changes that you have staged via the `store()` function.\n    fn commit(&mut self) -> io::Result<()> {\n        let mut uncommitted_files = std::mem::take(&mut self.staged_files);\n        let metadata_file = uncommitted_files.take(CratesCache::METADATA_FS);\n        for file in uncommitted_files {\n            let source = self.dir.join(&file).with_extension(\"part\");\n            let destination = self.dir.join(&file);\n            fs::rename(source, destination)?;\n        }\n        // metadata_file is special since it contains the timestamp for the cache.\n        // We will only commit it and update the timestamp if updating everything else succeeds.\n        // Otherwise it would be possible to create a partially updated cache that's considered fresh.\n        if let Some(file) = metadata_file {\n            let source = self.dir.join(&file).with_extension(\"part\");\n            let destination = self.dir.join(&file);\n            fs::rename(source, destination)?;\n        }\n        Ok(())\n    }\n\n    /// Does not overwrite existing data until `commit()` is called.\n    /// If you do not call `commit()` after this, the on-disk cache will not be actually updated!\n    fn store<T>(&mut self, cache: &mut Option<T>, file: &str, value: T) -> Result<(), io::Error>\n    where\n        T: Serialize,\n    {\n        *cache = None;\n        let value = cache.get_or_insert(value);\n\n        self.staged_files.insert(file.to_owned());\n        let out_path = self.dir.join(file).with_extension(\"part\");\n        let out_file = fs::File::create(out_path)?;\n        let out = io::BufWriter::new(out_file);\n        serde_json::to_writer(out, value)?;\n        Ok(())\n    }\n\n    fn store_map<T, K>(\n        &mut self,\n        cache: &mut Option<HashMap<K, T>>,\n        file: &str,\n        entries: &[T],\n        key_fn: &dyn Fn(&T) -> K,\n    ) -> Result<(), io::Error>\n    where\n        T: Serialize + Clone,\n        K: Serialize + Eq + std::hash::Hash,\n    {\n        let hashed: HashMap<K, _> = entries\n            .iter()\n            .map(|entry| (key_fn(entry), entry.clone()))\n            .collect();\n        self.store(cache, file, hashed)\n    }\n\n    fn store_multi_map<T, K>(\n        &mut self,\n        cache: &mut Option<HashMap<K, Vec<T>>>,\n        file: &str,\n        entries: &[T],\n        key_fn: &dyn Fn(&T) -> K,\n    ) -> Result<(), io::Error>\n    where\n        T: Serialize + Clone,\n        K: Serialize + Eq + std::hash::Hash,\n    {\n        let mut hashed: HashMap<K, _> = HashMap::new();\n        for entry in entries.iter() {\n            let key = key_fn(entry);\n            hashed\n                .entry(key)\n                .or_insert_with(Vec::new)\n                .push(entry.clone());\n        }\n        self.store(cache, file, hashed)\n    }\n}\n"
  },
  {
    "path": "src/main.rs",
    "content": "//! Gather author, contributor, publisher data on crates in your dependency graph.\n//!\n//! There are some use cases:\n//!\n//! * Find people and groups worth supporting.\n//! * An analysis of all the contributors you implicitly trust by building their software. This\n//!   might have both a sobering and humbling effect.\n//! * Identify risks in your dependency graph.\n\n#![forbid(unsafe_code)]\n\nmod api_client;\nmod cli;\nmod common;\nmod crates_cache;\nmod publishers;\nmod subcommands;\n\nuse cli::CliArgs;\nuse common::MetadataArgs;\n\nfn main() -> Result<(), anyhow::Error> {\n    let args = cli::args_parser().fallback_to_usage().run();\n    dispatch_command(args)\n}\n\nfn dispatch_command(args: CliArgs) -> Result<(), anyhow::Error> {\n    match args {\n        CliArgs::Publishers { args, meta_args } => {\n            subcommands::publishers(meta_args, args.diffable, args.cache_max_age)?;\n        }\n        CliArgs::Crates { args, meta_args } => {\n            subcommands::crates(meta_args, args.diffable, args.cache_max_age)?;\n        }\n        CliArgs::Update { cache_max_age } => subcommands::update(cache_max_age)?,\n        CliArgs::Json(json) => match json {\n            cli::PrintJson::Schema => subcommands::print_schema()?,\n            cli::PrintJson::Info { args, meta_args } => {\n                subcommands::json(meta_args, args.diffable, args.cache_max_age)?;\n            }\n        },\n    }\n\n    Ok(())\n}\n"
  },
  {
    "path": "src/publishers.rs",
    "content": "use crate::api_client::RateLimitedClient;\nuse crate::crates_cache::{CacheState, CratesCache};\nuse serde::{Deserialize, Serialize};\nuse std::{\n    collections::BTreeMap,\n    io::{self},\n    time::Duration,\n};\n\n#[cfg(test)]\nuse schemars::JsonSchema;\n\nuse crate::common::{crate_names_from_source, PkgSource, SourcedPackage};\n\n#[derive(Deserialize)]\nstruct UsersResponse {\n    users: Vec<PublisherData>,\n}\n\n#[derive(Deserialize)]\nstruct TeamsResponse {\n    teams: Vec<PublisherData>,\n}\n\n/// Data about a single publisher received from a crates.io API endpoint\n#[cfg_attr(test, derive(JsonSchema))]\n#[derive(Serialize, Deserialize, Debug, Clone)]\npub struct PublisherData {\n    pub id: u64,\n    pub login: String,\n    pub kind: PublisherKind,\n    // URL is disabled because it's present in API responses but not in DB dumps,\n    // so the output would vary inconsistent depending on data source\n    //pub url: Option<String>,\n    /// Display name. It is NOT guaranteed to be unique!\n    pub name: Option<String>,\n    /// Avatar image URL\n    pub avatar: Option<String>,\n}\n\nimpl PartialEq for PublisherData {\n    fn eq(&self, other: &Self) -> bool {\n        self.id == other.id\n    }\n}\n\nimpl Eq for PublisherData {\n    // holds for PublisherData because we're comparing u64 IDs, and it holds for u64\n    fn assert_receiver_is_total_eq(&self) {}\n}\n\nimpl PartialOrd for PublisherData {\n    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {\n        Some(self.id.cmp(&other.id))\n    }\n}\n\nimpl Ord for PublisherData {\n    fn cmp(&self, other: &Self) -> std::cmp::Ordering {\n        self.id.cmp(&other.id)\n    }\n}\n\n#[cfg_attr(test, derive(JsonSchema))]\n#[derive(Serialize, Deserialize, Debug, Copy, Clone, Ord, PartialOrd, Eq, PartialEq)]\n#[allow(non_camel_case_types)]\npub enum PublisherKind {\n    team,\n    user,\n}\n\npub fn publisher_users(\n    client: &mut RateLimitedClient,\n    crate_name: &str,\n) -> Result<Vec<PublisherData>, io::Error> {\n    let url = format!(\"https://crates.io/api/v1/crates/{}/owner_user\", crate_name);\n    let resp = get_with_retry(&url, client, 3)?;\n    let data: UsersResponse = resp.into_json()?;\n    Ok(data.users)\n}\n\npub fn publisher_teams(\n    client: &mut RateLimitedClient,\n    crate_name: &str,\n) -> Result<Vec<PublisherData>, io::Error> {\n    let url = format!(\"https://crates.io/api/v1/crates/{}/owner_team\", crate_name);\n    let resp = get_with_retry(&url, client, 3)?;\n    let data: TeamsResponse = resp.into_json()?;\n    Ok(data.teams)\n}\n\nfn get_with_retry(\n    url: &str,\n    client: &mut RateLimitedClient,\n    attempts: u8,\n) -> Result<ureq::Response, io::Error> {\n    let mut resp = client.get(url).call().map_err(io::Error::other)?;\n\n    let mut count = 1;\n    let mut wait = 5;\n    while resp.status() != 200 && count <= attempts {\n        eprintln!(\n            \"Failed retrieving {:?}, trying again in {} seconds, attempt {}/{}\",\n            url, wait, count, attempts\n        );\n        std::thread::sleep(std::time::Duration::from_secs(wait));\n\n        resp = client.get(url).call().map_err(io::Error::other)?;\n\n        count += 1;\n        wait *= 3;\n    }\n\n    Ok(resp)\n}\n\npub fn fetch_owners_of_crates(\n    dependencies: &[SourcedPackage],\n    max_age: Duration,\n) -> Result<\n    (\n        BTreeMap<String, Vec<PublisherData>>,\n        BTreeMap<String, Vec<PublisherData>>,\n    ),\n    io::Error,\n> {\n    let crates_io_names = crate_names_from_source(dependencies, PkgSource::CratesIo);\n    let mut client = RateLimitedClient::new();\n    let mut cached = CratesCache::new();\n    let using_cache = match cached.expire(max_age) {\n        CacheState::Fresh => true,\n        CacheState::Expired => {\n            eprintln!(\n                \"\\nIgnoring expired cache, older than {}.\",\n                // we use humantime rather than indicatif because we take humantime input\n                // and here we simply repeat it back to the user\n                humantime::format_duration(max_age)\n            );\n            eprintln!(\"  Run `cargo supply-chain update` to update it.\");\n            false\n        }\n        CacheState::Unknown => {\n            eprintln!(\"\\nThe `crates.io` cache was not found or it is invalid.\");\n            eprintln!(\"  Run `cargo supply-chain update` to generate it.\");\n            false\n        }\n    };\n    let mut users: BTreeMap<String, Vec<PublisherData>> = BTreeMap::new();\n    let mut teams: BTreeMap<String, Vec<PublisherData>> = BTreeMap::new();\n\n    if using_cache {\n        let age = cached.age().unwrap();\n        eprintln!(\n            \"\\nUsing cached data. Cache age: {}\",\n            indicatif::HumanDuration(age)\n        );\n    } else {\n        eprintln!(\"\\nFetching publisher info from crates.io\");\n        eprintln!(\"This will take roughly 2 seconds per crate due to API rate limits\");\n    }\n\n    let bar = indicatif::ProgressBar::new(crates_io_names.len() as u64)\n    .with_prefix(\"Preparing\")\n    .with_style(\n        indicatif::ProgressStyle::default_bar()\n        .template(\"{prefix:>12.bright.cyan} [{bar:27}] {pos:>4}/{len:4} ETA {eta:3} - {msg:.cyan}\").unwrap()\n        .progress_chars(\"=> \")\n    );\n\n    for (i, crate_name) in crates_io_names.iter().enumerate() {\n        bar.set_message(crate_name.clone());\n        bar.set_position((i + 1) as u64);\n        let cached_users = cached.publisher_users(crate_name);\n        let cached_teams = cached.publisher_teams(crate_name);\n        if let (Some(pub_users), Some(pub_teams)) = (cached_users, cached_teams) {\n            bar.set_prefix(\"Loading cache\");\n            users.insert(crate_name.clone(), pub_users);\n            teams.insert(crate_name.clone(), pub_teams);\n        } else {\n            // Handle crates not found in the cache by fetching live data for them\n            bar.set_prefix(\"Downloading\");\n            let pusers = publisher_users(&mut client, crate_name)?;\n            users.insert(crate_name.clone(), pusers);\n            let pteams = publisher_teams(&mut client, crate_name)?;\n            teams.insert(crate_name.clone(), pteams);\n        }\n    }\n    Ok((users, teams))\n}\n"
  },
  {
    "path": "src/subcommands/crates.rs",
    "content": "use crate::publishers::{fetch_owners_of_crates, PublisherKind};\nuse crate::{\n    common::{comma_separated_list, complain_about_non_crates_io_crates, sourced_dependencies},\n    MetadataArgs,\n};\n\npub fn crates(\n    metadata_args: MetadataArgs,\n    diffable: bool,\n    max_age: std::time::Duration,\n) -> Result<(), anyhow::Error> {\n    let dependencies = sourced_dependencies(metadata_args)?;\n    complain_about_non_crates_io_crates(&dependencies);\n    let (mut owners, publisher_teams) = fetch_owners_of_crates(&dependencies, max_age)?;\n\n    for (crate_name, publishers) in publisher_teams {\n        owners.entry(crate_name).or_default().extend(publishers);\n    }\n\n    let mut ordered_owners: Vec<_> = owners.into_iter().collect();\n    if diffable {\n        // Sort alphabetically by crate name\n        ordered_owners.sort_unstable_by_key(|(name, _)| name.clone());\n    } else {\n        // Order by the number of owners, but put crates owned by teams first\n        ordered_owners.sort_unstable_by_key(|(name, publishers)| {\n            (\n                !publishers.iter().any(|p| p.kind == PublisherKind::team), // contains at least one team\n                usize::MAX - publishers.len(),\n                name.clone(),\n            )\n        });\n    }\n    for (_, publishers) in &mut ordered_owners {\n        // For each crate put teams first\n        publishers.sort_unstable_by_key(|p| (p.kind, p.login.clone()));\n    }\n\n    if !diffable {\n        println!(\n            \"\\nDependency crates with the people and teams that can publish them to crates.io:\\n\"\n        );\n    }\n    for (i, (crate_name, publishers)) in ordered_owners.iter().enumerate() {\n        let pretty_publishers: Vec<String> = publishers\n            .iter()\n            .map(|p| match p.kind {\n                PublisherKind::team => format!(\"team \\\"{}\\\"\", p.login),\n                PublisherKind::user => p.login.to_string(),\n            })\n            .collect();\n        let publishers_list = comma_separated_list(&pretty_publishers);\n        if diffable {\n            println!(\"{}: {}\", crate_name, publishers_list);\n        } else {\n            println!(\"{}. {}: {}\", i + 1, crate_name, publishers_list);\n        }\n    }\n\n    if !ordered_owners.is_empty() {\n        eprintln!(\"\\nNote: there may be outstanding publisher invitations. crates.io provides no way to list them.\");\n        eprintln!(\"See https://github.com/rust-lang/crates.io/issues/2868 for more info.\");\n    }\n    Ok(())\n}\n"
  },
  {
    "path": "src/subcommands/json.rs",
    "content": "//! `json` subcommand is equivalent to `crates`,\n//! but provides structured output and more info about each publisher.\nuse crate::publishers::{fetch_owners_of_crates, PublisherData};\nuse crate::{\n    common::{crate_names_from_source, sourced_dependencies, PkgSource},\n    MetadataArgs,\n};\nuse serde::Serialize;\nuse std::collections::BTreeMap;\n\n#[cfg(test)]\nuse schemars::JsonSchema;\n\n#[cfg_attr(test, derive(JsonSchema))]\n#[derive(Debug, Serialize, Default, Clone)]\npub struct StructuredOutput {\n    not_audited: NotAudited,\n    /// Maps crate names to info about the publishers of each crate\n    crates_io_crates: BTreeMap<String, Vec<PublisherData>>,\n}\n\n#[cfg_attr(test, derive(JsonSchema))]\n#[derive(Debug, Serialize, Default, Clone)]\npub struct NotAudited {\n    /// Names of crates that are imported from a location in the local filesystem, not from a registry\n    local_crates: Vec<String>,\n    /// Names of crates that are neither from crates.io nor from a local filesystem\n    foreign_crates: Vec<String>,\n}\n\npub fn json(\n    args: MetadataArgs,\n    diffable: bool,\n    max_age: std::time::Duration,\n) -> Result<(), anyhow::Error> {\n    let mut output = StructuredOutput::default();\n    let dependencies = sourced_dependencies(args)?;\n    // Report non-crates.io dependencies\n    output.not_audited.local_crates = crate_names_from_source(&dependencies, PkgSource::Local);\n    output.not_audited.foreign_crates = crate_names_from_source(&dependencies, PkgSource::Foreign);\n    output.not_audited.local_crates.sort_unstable();\n    output.not_audited.foreign_crates.sort_unstable();\n    // Fetch list of owners and publishers\n    let (mut owners, publisher_teams) = fetch_owners_of_crates(&dependencies, max_age)?;\n    // Merge the two maps we received into one\n    for (crate_name, publishers) in publisher_teams {\n        owners.entry(crate_name).or_default().extend(publishers);\n    }\n    // Sort the vectors of publisher data. This helps when diffing the output,\n    // but we do it unconditionally because it's cheap and helps users pull less hair when debugging.\n    for list in owners.values_mut() {\n        list.sort_unstable_by_key(|x| x.id);\n    }\n    output.crates_io_crates = owners;\n    // Print the result to stdout\n    let stdout = std::io::stdout();\n    let handle = stdout.lock();\n    if diffable {\n        serde_json::to_writer_pretty(handle, &output)?;\n    } else {\n        serde_json::to_writer(handle, &output)?;\n    }\n    Ok(())\n}\n"
  },
  {
    "path": "src/subcommands/json_schema.rs",
    "content": "//! The schema for the JSON subcommand output\n\nuse std::io::{Result, Write};\n\npub fn print_schema() -> Result<()> {\n    writeln!(std::io::stdout(), \"{}\", JSON_SCHEMA)?;\n    Ok(())\n}\n\nconst JSON_SCHEMA: &str = r##\"{\n  \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n  \"title\": \"StructuredOutput\",\n  \"type\": \"object\",\n  \"required\": [\n    \"crates_io_crates\",\n    \"not_audited\"\n  ],\n  \"properties\": {\n    \"crates_io_crates\": {\n      \"description\": \"Maps crate names to info about the publishers of each crate\",\n      \"type\": \"object\",\n      \"additionalProperties\": {\n        \"type\": \"array\",\n        \"items\": {\n          \"$ref\": \"#/definitions/PublisherData\"\n        }\n      }\n    },\n    \"not_audited\": {\n      \"$ref\": \"#/definitions/NotAudited\"\n    }\n  },\n  \"definitions\": {\n    \"NotAudited\": {\n      \"type\": \"object\",\n      \"required\": [\n        \"foreign_crates\",\n        \"local_crates\"\n      ],\n      \"properties\": {\n        \"foreign_crates\": {\n          \"description\": \"Names of crates that are neither from crates.io nor from a local filesystem\",\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          }\n        },\n        \"local_crates\": {\n          \"description\": \"Names of crates that are imported from a location in the local filesystem, not from a registry\",\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          }\n        }\n      }\n    },\n    \"PublisherData\": {\n      \"description\": \"Data about a single publisher received from a crates.io API endpoint\",\n      \"type\": \"object\",\n      \"required\": [\n        \"id\",\n        \"kind\",\n        \"login\"\n      ],\n      \"properties\": {\n        \"avatar\": {\n          \"description\": \"Avatar image URL\",\n          \"type\": [\n            \"string\",\n            \"null\"\n          ]\n        },\n        \"id\": {\n          \"type\": \"integer\",\n          \"format\": \"uint64\",\n          \"minimum\": 0.0\n        },\n        \"kind\": {\n          \"$ref\": \"#/definitions/PublisherKind\"\n        },\n        \"login\": {\n          \"type\": \"string\"\n        },\n        \"name\": {\n          \"description\": \"Display name. It is NOT guaranteed to be unique!\",\n          \"type\": [\n            \"string\",\n            \"null\"\n          ]\n        }\n      }\n    },\n    \"PublisherKind\": {\n      \"type\": \"string\",\n      \"enum\": [\n        \"team\",\n        \"user\"\n      ]\n    }\n  }\n}\"##;\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::subcommands::json::StructuredOutput;\n    use schemars::schema_for;\n\n    #[test]\n    fn test_json_schema() {\n        let schema = schema_for!(StructuredOutput);\n        let schema = serde_json::to_string_pretty(&schema).unwrap();\n        assert_eq!(schema, JSON_SCHEMA);\n    }\n}\n"
  },
  {
    "path": "src/subcommands/mod.rs",
    "content": "pub mod crates;\npub mod json;\npub mod json_schema;\npub mod publishers;\npub mod update;\n\npub use crates::crates;\npub use json::json;\npub use json_schema::print_schema;\npub use publishers::publishers;\npub use update::update;\n"
  },
  {
    "path": "src/subcommands/publishers.rs",
    "content": "use std::collections::BTreeMap;\n\nuse crate::publishers::fetch_owners_of_crates;\nuse crate::MetadataArgs;\nuse crate::{\n    common::{comma_separated_list, complain_about_non_crates_io_crates, sourced_dependencies},\n    publishers::PublisherData,\n};\n\npub fn publishers(\n    metadata_args: MetadataArgs,\n    diffable: bool,\n    max_age: std::time::Duration,\n) -> Result<(), anyhow::Error> {\n    let dependencies = sourced_dependencies(metadata_args)?;\n    complain_about_non_crates_io_crates(&dependencies);\n    let (publisher_users, publisher_teams) = fetch_owners_of_crates(&dependencies, max_age)?;\n\n    // Group data by user rather than by crate\n    let mut user_to_crate_map = transpose_publishers_map(&publisher_users);\n    let mut team_to_crate_map = transpose_publishers_map(&publisher_teams);\n\n    // Sort crate names alphabetically\n    user_to_crate_map.values_mut().for_each(|c| c.sort());\n    team_to_crate_map.values_mut().for_each(|c| c.sort());\n\n    if diffable {\n        // empty map just means 0 loop iterations here\n        let sorted_map = sort_transposed_map_for_diffing(user_to_crate_map);\n        for (user, crates) in &sorted_map {\n            let crate_list = comma_separated_list(crates);\n            println!(\"user \\\"{}\\\": {}\", &user.login, crate_list);\n        }\n    } else if !publisher_users.is_empty() {\n        println!(\"\\nThe following individuals can publish updates for your dependencies:\\n\");\n        let map_for_display = sort_transposed_map_for_display(user_to_crate_map);\n        for (i, (user, crates)) in map_for_display.iter().enumerate() {\n            // We do not print usernames, since you can embed terminal control sequences in them\n            // and erase yourself from the output that way.\n            let crate_list = comma_separated_list(crates);\n            println!(\" {}. {} via crates: {}\", i + 1, &user.login, crate_list);\n        }\n        eprintln!(\"\\nNote: there may be outstanding publisher invitations. crates.io provides no way to list them.\");\n        eprintln!(\"See https://github.com/rust-lang/crates.io/issues/2868 for more info.\");\n    }\n\n    if diffable {\n        let sorted_map = sort_transposed_map_for_diffing(team_to_crate_map);\n        for (team, crates) in &sorted_map {\n            let crate_list = comma_separated_list(crates);\n            println!(\"team \\\"{}\\\": {}\", &team.login, crate_list);\n        }\n    } else if !publisher_teams.is_empty() {\n        println!(\n            \"\\nAll members of the following teams can publish updates for your dependencies:\\n\"\n        );\n        let map_for_display = sort_transposed_map_for_display(team_to_crate_map);\n        for (i, (team, crates)) in map_for_display.iter().enumerate() {\n            let crate_list = comma_separated_list(crates);\n            if let (true, Some(org)) = (\n                team.login.starts_with(\"github:\"),\n                team.login.split(':').nth(1),\n            ) {\n                println!(\n                    \" {}. \\\"{}\\\" (https://github.com/{}) via crates: {}\",\n                    i + 1,\n                    &team.login,\n                    org,\n                    crate_list\n                );\n            } else {\n                println!(\" {}. \\\"{}\\\" via crates: {}\", i + 1, &team.login, crate_list);\n            }\n        }\n        eprintln!(\"\\nGithub teams are black boxes. It's impossible to get the member list without explicit permission.\");\n    }\n    Ok(())\n}\n\n/// Turns a crate-to-publishers mapping into publisher-to-crates mapping.\n/// [`BTreeMap`] is used because [`PublisherData`] doesn't implement Hash.\nfn transpose_publishers_map(\n    input: &BTreeMap<String, Vec<PublisherData>>,\n) -> BTreeMap<PublisherData, Vec<String>> {\n    let mut result: BTreeMap<PublisherData, Vec<String>> = BTreeMap::new();\n    for (crate_name, publishers) in input.iter() {\n        for publisher in publishers {\n            result\n                .entry(publisher.clone())\n                .or_default()\n                .push(crate_name.clone());\n        }\n    }\n    result\n}\n\n/// Returns a Vec sorted so that publishers are sorted by the number of crates they control.\n/// If that number is the same, sort by login.\nfn sort_transposed_map_for_display(\n    input: BTreeMap<PublisherData, Vec<String>>,\n) -> Vec<(PublisherData, Vec<String>)> {\n    let mut result: Vec<_> = input.into_iter().collect();\n    result.sort_unstable_by_key(|(publisher, crates)| {\n        (usize::MAX - crates.len(), publisher.login.clone())\n    });\n    result\n}\n\nfn sort_transposed_map_for_diffing(\n    input: BTreeMap<PublisherData, Vec<String>>,\n) -> Vec<(PublisherData, Vec<String>)> {\n    let mut result: Vec<_> = input.into_iter().collect();\n    result.sort_unstable_by_key(|(publisher, _crates)| publisher.login.clone());\n    result\n}\n"
  },
  {
    "path": "src/subcommands/update.rs",
    "content": "use crate::api_client::RateLimitedClient;\nuse crate::crates_cache::{CratesCache, DownloadState};\nuse anyhow::bail;\n\npub fn update(max_age: std::time::Duration) -> Result<(), anyhow::Error> {\n    let mut cache = CratesCache::new();\n    let mut client = RateLimitedClient::new();\n\n    match cache.download(&mut client, max_age) {\n        Ok(state) => match state {\n            DownloadState::Fresh => eprintln!(\"No updates found\"),\n            DownloadState::Expired => {\n                eprintln!(\"Successfully updated to the newest daily data dump.\");\n            }\n            DownloadState::Stale => bail!(\"Latest daily data dump matches the previous version, which was considered outdated.\"),\n        },\n        Err(error) => bail!(\"Could not update to the latest daily data dump!\\n{}\", error)\n    }\n    Ok(())\n}\n"
  }
]