1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
// Copyright 2021 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

//! This test exercises the `update_crates` tool against a golden project.
//!
//! To add "test cases," define new crates in `./local_registry_sources` and include them in
//! `./BUILD.gn` under `uses_local_registry_test_data`'s `sources` to ensure they're copied to CQ's
//! test runners. Any crates in `./local_registry_sources` on the test runner will be included in
//! the custom local registry used for update queries.
//!
//! Once the crates are in the test registry, depend on those crates in
//! `./uses_local_registry/Cargo.toml` and add the expected post-update state to
//! `./uses_local_registry/Cargo.expected.toml`.
//!
//! The `update_crates` tool can also be configured at `./uses_local_registry/outdated.toml`.

use argh::FromArgs;
use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{
    collections::{BTreeMap, BTreeSet},
    env,
    ffi::OsStr,
    fs::File,
    io::Write,
    iter,
    path::{Path, PathBuf},
    process::Command,
    sync::Mutex,
};
use tempfile::TempDir;
use walkdir::WalkDir;

/// an integration test for the update_crates host tool
#[derive(Debug, FromArgs)]
struct TestArgs {
    /// path to the tests directory
    #[argh(option)]
    test_base_dir: PathBuf,
    /// path to the bin/ dir within our rust prebuilt distro
    #[argh(option)]
    rust_bin_dir: PathBuf,
    /// path to prebuilt cargo-outdated
    #[argh(option)]
    cargo_outdated: PathBuf,
    /// path to update_crates binary to test
    #[argh(option)]
    update_crates: PathBuf,
}

impl TestArgs {
    /// Get absolute paths for each of these so we can pass them to subprocesses with different
    /// working directories than our own.
    fn canonicalize(mut self) -> Self {
        self.test_base_dir = std::fs::canonicalize(self.test_base_dir).unwrap();
        self.rust_bin_dir = std::fs::canonicalize(self.rust_bin_dir).unwrap();
        self.cargo_outdated = std::fs::canonicalize(self.cargo_outdated).unwrap();
        self.update_crates = std::fs::canonicalize(self.update_crates).unwrap();
        self
    }
}

fn main() {
    let TestArgs { test_base_dir, rust_bin_dir, cargo_outdated, update_crates } =
        argh::from_env::<TestArgs>().canonicalize();

    // copy everything to a temporary directory and shadow variable name so we don't modify source
    let test_base_dir = setup_test_directory(test_base_dir);

    // add our rust distribution to our PATH
    let existing_path = env::var("PATH").unwrap();
    let new_path =
        env::join_paths(iter::once(rust_bin_dir.clone()).chain(env::split_paths(&existing_path)))
            .unwrap();
    env::set_var("PATH", new_path);

    let test_project_root = test_base_dir.join("uses_local_registry");
    // remove potentially stale lockfile in case of hash collisions during development
    std::fs::remove_file(test_project_root.join("Cargo.lock")).ok();

    // populate the local registry
    let registry_path = test_base_dir.join("registry");
    let config_contents =
        make_test_registry(test_base_dir.join("local_registry_sources"), &registry_path);
    // populate the `.cargo/config.toml` which overrides with our local registry
    let dot_cargo = test_project_root.join(".cargo");
    std::fs::create_dir_all(&dot_cargo).unwrap();
    std::fs::write(dot_cargo.join("config.toml"), config_contents).unwrap();

    // run the update tool
    let test_project_manifest = test_project_root.join("Cargo.toml");
    Command::new(update_crates)
        .arg("--manifest-path")
        .arg(&test_project_manifest)
        .arg("--overrides")
        .arg(test_project_root.join("outdated.toml"))
        .arg("update")
        .arg("--cargo")
        .arg(rust_bin_dir.join("cargo"))
        .arg("--outdated-dir")
        .arg(cargo_outdated.parent().unwrap())
        .arg("--offline")
        // use a temp directory so that the workstation environment is close to CQ
        .env("CARGO_HOME", test_base_dir.join("cargo_home"))
        // we need to set cwd so that cargo-outdated picks up the .cargo/config.toml we wrote
        // (this is why we need to canonicalize the args above)
        .current_dir(&test_project_root)
        .output()
        .unwrap_success();

    // make sure the tool did what we expect
    let observed_manifest_after_update = std::fs::read_to_string(test_project_manifest).unwrap();
    let expected_manifest_after_update =
        std::fs::read_to_string(test_project_root.join("Cargo.expected.toml")).unwrap();
    assert_eq!(observed_manifest_after_update, expected_manifest_after_update);
}

fn setup_test_directory(test_source_dir: PathBuf) -> PathBuf {
    /// We put the temp dir in a static so that a panic can suppress its cleanup routine.
    static TEST_DIR: Lazy<Mutex<Option<TempDir>>> = Lazy::new(|| Mutex::new(None));

    let temp_test_dir = TempDir::new().unwrap();
    let output_path = temp_test_dir.path().to_owned();
    *TEST_DIR.lock().unwrap() = Some(temp_test_dir);

    // install a panic hook that will leave the directory in place, printing the path
    let prev_panic_hook = std::panic::take_hook();
    std::panic::set_hook(Box::new(move |info| {
        if let Some(temp_test_dir) = TEST_DIR.lock().unwrap().take() {
            let temp_path = temp_test_dir.into_path(); // avoids the cleanup dtor
            eprintln!("left test directory persisted at {}", temp_path.display());
        }
        prev_panic_hook(info);
    }));

    // copy everything from test_source_dir to output_path
    for entry in WalkDir::new(&test_source_dir) {
        let entry = entry.unwrap();
        let source = entry.path();
        let suffix = source.strip_prefix(&test_source_dir).unwrap();
        let target = output_path.join(suffix);

        if let Err(e) = if source.is_file() {
            std::fs::copy(source, &target).map(|_| ())
        } else if source.is_dir() {
            std::fs::create_dir_all(&target)
        } else {
            unreachable!("no special files should be in test source directory");
        } {
            panic!("copying {} to {} failed: {}", source.display(), target.display(), e);
        }
    }

    output_path
}

trait UnwrapSuccess {
    #[track_caller]
    fn unwrap_success(self);
}

impl<E: std::fmt::Debug> UnwrapSuccess for Result<std::process::Output, E> {
    fn unwrap_success(self) {
        let output = self.unwrap();
        if !output.status.success() {
            panic!(
                "command failed: {}\nstdout:\n{}\nstderr:\n{}",
                output.status,
                String::from_utf8_lossy(&output.stdout),
                String::from_utf8_lossy(&output.stderr)
            )
        }
    }
}

/// Creates a test registry at the provided path, returning the contents of a `.cargo/config.toml`
/// that makes use of it.
fn make_test_registry(sources: PathBuf, registry_path: &Path) -> String {
    std::fs::remove_dir_all(&registry_path).ok(); // this will fail if this is a clean builder

    let mut packages: BTreeMap<PathBuf, IndexEntry> = Default::default();
    for entry in std::fs::read_dir(sources).unwrap() {
        let manifest = entry.unwrap().path().join("Cargo.toml");

        let (package_name, version) = CrateVersion::new(manifest);
        let index_file_path = registry_path.join("index").join(index_subpath(&package_name));

        packages.entry(index_file_path).or_default().versions.insert(version);
    }

    for (index_file_path, entry) in packages {
        entry.populate_in_index(&registry_path, &index_file_path);
    }

    format!(
        "\
            [source.crates-io]
            registry = 'https://github.com/rust-lang/crates.io-index'
            replace-with = 'local-registry'

            [source.local-registry]
            local-registry = '{}'
            ",
        registry_path.display()
    )
}

#[derive(Clone, Debug, Default, Eq, Hash, PartialEq, PartialOrd, Ord)]
struct IndexEntry {
    versions: BTreeSet<CrateVersion>,
}

impl IndexEntry {
    fn populate_in_index(self, registry_path: &Path, destination: &Path) {
        std::fs::create_dir_all(destination.parent().unwrap()).unwrap();
        let mut index_file = File::create(destination).unwrap();
        for version in self.versions {
            // add a line to the json file
            serde_json::to_writer(&mut index_file, &version.metadata).unwrap();
            index_file.write_all(b"\n").unwrap();

            // copy the .crate file to the registry
            let crate_destination = registry_path.join(version.crate_source.file_name().unwrap());
            std::fs::copy(&version.crate_source, crate_destination).unwrap();
        }
    }
}

#[derive(Clone, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)]
struct CrateVersion {
    version: String,
    crate_source: PathBuf,
    metadata: VersionMetadata,
}

impl CrateVersion {
    /// runs `cargo package` on the manifest and returns the name of the package and a path to the
    /// `.crate` file produced
    fn new(manifest_path: PathBuf) -> (String, Self) {
        Command::new("cargo")
            .arg("package")
            .arg("--allow-dirty")
            .arg("--manifest-path")
            .arg(&manifest_path)
            .output()
            .unwrap_success();

        let package_dir = manifest_path.parent().unwrap().join("target").join("package");
        let crate_source = std::fs::read_dir(package_dir)
            .unwrap()
            .map(|e| e.unwrap().path().to_owned())
            .filter(|p| p.extension() == Some(OsStr::new("crate")))
            .next()
            .unwrap();

        let manifest_contents = std::fs::read_to_string(&manifest_path).unwrap();
        let manifest: toml::Value = toml::from_str(&manifest_contents).unwrap();
        let package_name = manifest["package"]["name"].as_str().unwrap().to_string();
        let version = manifest["package"]["version"].as_str().unwrap().to_string();

        let crate_file_contents = std::fs::read(&crate_source).unwrap();

        let mut digest = Sha256::new();
        digest.update(&crate_file_contents);
        let cksum = hex::encode(digest.finalize());

        let metadata = VersionMetadata {
            name: package_name.clone(),
            vers: version.clone(),
            deps: vec![],
            cksum,
            features: Default::default(),
            yanked: false,
            links: None,
        };

        (package_name, Self { crate_source, version, metadata })
    }
}

/// from https://doc.rust-lang.org/cargo/reference/registries.html:
///
/// Each line in a package file contains a JSON object that describes a published version of the
/// package.
#[derive(Clone, Debug, Eq, Hash, PartialEq, PartialOrd, Ord, Deserialize, Serialize)]
struct VersionMetadata {
    /// The name of the package. This must only contain alphanumeric, `-`, or `_` characters.
    name: String,
    /// The version of the package this row is describing. This must be a valid version number
    /// according to the Semantic Versioning 2.0.0 spec at https://semver.org/.
    vers: String,
    /// Array of direct dependencies of the package.
    deps: Vec<DependencyMetadata>,
    /// A SHA256 checksum of the `.crate` file.
    cksum: String,
    /// Set of features defined for the package. Each feature maps to an array of features or
    /// dependencies it enables.
    features: BTreeMap<String, Vec<String>>,
    /// Boolean of whether or not this version has been yanked.
    yanked: bool,
    /// The `links` string value from the package's manifest, or null if not specified. This field
    /// is optional and defaults to null.
    links: Option<String>,
}

#[allow(unused)]
#[derive(Clone, Debug, Eq, Hash, PartialEq, PartialOrd, Ord, Deserialize, Serialize)]
struct DependencyMetadata {
    /// Name of the dependency.
    /// If the dependency is renamed from the original package name,
    /// this is the new name. The original package name is stored in
    /// the `package` field.
    name: String,
    /// The semver requirement for this dependency.
    /// This must be a valid version requirement defined at
    /// https://github.com/steveklabnik/semver#requirements.
    req: String,
    /// Array of features (as strings) enabled for this dependency.
    features: Vec<String>,
    /// Boolean of whether or not this is an optional dependency.
    optional: bool,
    /// Boolean of whether or not default features are enabled.
    default_features: bool,
    /// The target platform for the dependency.
    /// null if not a target dependency.
    /// Otherwise, a string such as "cfg(windows)".
    target: Option<String>,
    /// The dependency kind.
    /// "dev", "build", or "normal".
    /// Note: this is a required field, but a small number of entries
    /// exist in the crates.io index with either a missing or null
    /// `kind` field due to implementation bugs.
    kind: DepKind,
    /// The URL of the index of the registry where this dependency is
    /// from as a string. If not specified or null, it is assumed the
    /// dependency is in the current registry.
    registry: Option<String>,
    /// If the dependency is renamed, this is a string of the actual
    /// package name. If not specified or null, this dependency is not
    /// renamed.
    package: Option<String>,
}

#[allow(unused)]
#[derive(Clone, Debug, Eq, Hash, PartialEq, PartialOrd, Ord, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
enum DepKind {
    Dev,
    Build,
    Normal,
}

/// from https://doc.rust-lang.org/cargo/reference/registries.html:
///
/// The rest of the index repository contains one file for each package, where the filename is the
/// name of the package in lowercase. Each version of the package has a separate line in the file.
/// The files are organized in a tier of directories:
///
/// * Packages with 1 character names are placed in a directory named `1`.
/// * Packages with 2 character names are placed in a directory named `2`.
/// * Packages with 3 character names are placed in the directory `3/{first-character}` where
///   `{first-character}` is the first character of the package name.
/// * All other packages are stored in directories named `{first-two}/{second-two}` where the top
///   directory is the first two characters of the package name, and the next subdirectory is the
///   third and fourth characters of the package name. For example, `cargo` would be stored in a
///   file named `ca/rg/cargo`.
fn index_subpath(package_name: &str) -> PathBuf {
    let package_name = package_name.to_ascii_lowercase();
    match package_name.len() {
        0 => unreachable!("disallowed by cargo's rules"),
        1 | 2 | 3 => unreachable!("requires special behavior not needed for this test"),
        _ => {
            let first_two = package_name.split_at(2).0;
            let second_two = package_name.split_at(4).0.split_at(2).1;
            PathBuf::from(first_two).join(second_two)
        }
        .join(package_name),
    }
}