Skip to main content

analyzed_bridge/
lib.rs

1use std::{
2    env,
3    error::Error,
4    fs,
5    path::{Component, Path, PathBuf},
6    process::Command,
7};
8
9use flate2::read::GzDecoder;
10use sha2::{Digest, Sha256};
11use toml::{Table, Value, map::Map};
12
13mod edit;
14
15pub use edit::*;
16pub use ra_ap_syntax::ast;
17
18#[derive(Debug)]
19pub struct LockedPackage {
20    pub version: String,
21    pub checksum: String,
22    pub git_revision: Option<String>,
23}
24
25pub fn prepare_bridge_package(
26    package_name: &str,
27    generated_dir: &str,
28) -> Result<(PathBuf, LockedPackage), Box<dyn Error>> {
29    let manifest = bridge_manifest_path();
30    let (mut package, lock) = locked_package(package_name, &manifest)?;
31    let archive = registry_archive(package_name, &package, &manifest)?;
32    verify_archive_checksum(&archive, &package)?;
33
34    let generated =
35        PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR is set by Cargo")).join(generated_dir);
36    unpack_crate_archive(package_name, &archive, &generated, &package)?;
37    verify_manifest_matches_bridge(package_name, &generated.join("Cargo.toml"), &manifest)?;
38    package.git_revision = crate_git_revision(&generated)?;
39    rewrite_lib_header(&generated.join("src/lib.rs"))?;
40
41    println!("cargo:rerun-if-changed={}", archive.display());
42    println!("cargo:rerun-if-changed={}", manifest.display());
43    println!("cargo:rerun-if-changed={}", lock.display());
44
45    Ok((generated, package))
46}
47
48fn registry_archive(
49    package_name: &str,
50    package: &LockedPackage,
51    manifest: &Path,
52) -> Result<PathBuf, Box<dyn Error>> {
53    let cargo_home = cargo_home()?;
54    if let Some(archive) = find_registry_archive(package_name, &cargo_home, package)? {
55        return Ok(archive);
56    }
57
58    fetch_registry_packages(manifest)?;
59
60    if let Some(archive) = find_registry_archive(package_name, &cargo_home, package)? {
61        return Ok(archive);
62    }
63
64    Err(format!(
65        "could not find {} under {} after `cargo fetch --locked`",
66        archive_name(package_name, package),
67        cargo_home.join("registry").join("cache").display()
68    )
69    .into())
70}
71
72fn cargo_home() -> Result<PathBuf, Box<dyn Error>> {
73    env::var_os("CARGO_HOME")
74        .map(PathBuf::from)
75        .or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".cargo")))
76        .or_else(|| env::var_os("USERPROFILE").map(|home| PathBuf::from(home).join(".cargo")))
77        .ok_or_else(|| "CARGO_HOME is unavailable".into())
78}
79
80fn find_registry_archive(
81    package_name: &str,
82    cargo_home: &Path,
83    package: &LockedPackage,
84) -> Result<Option<PathBuf>, Box<dyn Error>> {
85    let registry_cache = cargo_home.join("registry").join("cache");
86    let package_archive = archive_name(package_name, package);
87    let Ok(registries) = fs::read_dir(&registry_cache) else {
88        return Ok(None);
89    };
90
91    for registry in registries {
92        let candidate = registry?.path().join(&package_archive);
93        if candidate.is_file() {
94            return Ok(Some(candidate));
95        }
96    }
97
98    Ok(None)
99}
100
101fn fetch_registry_packages(manifest: &Path) -> Result<(), Box<dyn Error>> {
102    let cargo = env::var_os("CARGO").ok_or("CARGO is unavailable")?;
103    let status = Command::new(cargo)
104        .arg("fetch")
105        .arg("--locked")
106        .arg("--manifest-path")
107        .arg(manifest)
108        .status()?;
109
110    if !status.success() {
111        return Err(format!("cargo fetch --locked failed with {status}").into());
112    }
113
114    Ok(())
115}
116
117fn verify_archive_checksum(archive: &Path, package: &LockedPackage) -> Result<(), Box<dyn Error>> {
118    let actual = hex_digest(fs::read(archive)?);
119
120    if actual != package.checksum {
121        return Err(format!(
122            "checksum mismatch for {}: expected {}, got {actual}",
123            archive.display(),
124            package.checksum,
125        )
126        .into());
127    }
128
129    Ok(())
130}
131
132fn locked_package(
133    package_name: &str,
134    manifest: &Path,
135) -> Result<(LockedPackage, PathBuf), Box<dyn Error>> {
136    let version = upstream_package_version(package_name, manifest)?;
137    let locks = lockfile_candidates(manifest);
138
139    for lock in locks.iter().filter(|lock| lock.is_file()) {
140        if let Some(checksum) = locked_package_checksum(package_name, lock, &version)? {
141            return Ok((
142                LockedPackage {
143                    version: version.clone(),
144                    checksum,
145                    git_revision: None,
146                },
147                lock.clone(),
148            ));
149        }
150    }
151
152    Err(format!(
153        "could not find registry {package_name} {version} checksum in {}",
154        locks
155            .iter()
156            .map(|lock| lock.display().to_string())
157            .collect::<Vec<_>>()
158            .join(", ")
159    )
160    .into())
161}
162
163fn bridge_manifest_path() -> PathBuf {
164    PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set by Cargo"))
165        .join("Cargo.toml")
166}
167
168fn upstream_package_version(package_name: &str, manifest: &Path) -> Result<String, Box<dyn Error>> {
169    let manifest = read_manifest(manifest)?;
170    let dependencies = manifest_table(&manifest, &["target", "cfg(any())", "dependencies"])
171        .ok_or("bridge manifest has no cfg(any()) source dependency")?;
172    let version = dependencies
173        .iter()
174        .find(|(name, dependency)| dependency_package_name(name, dependency) == package_name)
175        .and_then(|(_, dependency)| dependency_version(dependency))
176        .ok_or_else(|| {
177            format!("bridge manifest has no cfg(any()) dependency for {package_name}")
178        })?;
179
180    exact_version(version)
181}
182
183fn dependency_package_name<'a>(name: &'a str, dependency: &'a Value) -> &'a str {
184    dependency
185        .as_table()
186        .and_then(|dependency| dependency.get("package"))
187        .and_then(Value::as_str)
188        .unwrap_or(name)
189}
190
191fn dependency_version(dependency: &Value) -> Option<&str> {
192    match dependency {
193        Value::String(version) => Some(version),
194        Value::Table(dependency) => dependency.get("version").and_then(Value::as_str),
195        _ => None,
196    }
197}
198
199fn exact_version(version: &str) -> Result<String, Box<dyn Error>> {
200    version
201        .strip_prefix('=')
202        .map(str::trim)
203        .filter(|version| !version.is_empty())
204        .map(str::to_owned)
205        .ok_or_else(|| {
206            format!("bridge source dependency version must be exact, got {version}").into()
207        })
208}
209
210fn lockfile_candidates(manifest: &Path) -> Vec<PathBuf> {
211    let manifest_dir = manifest
212        .parent()
213        .expect("manifest path has a parent directory");
214    let package_lock = manifest_dir.join("Cargo.lock");
215    let workspace_lock = manifest_dir.join("../..").join("Cargo.lock");
216    if package_lock == workspace_lock {
217        vec![package_lock]
218    } else {
219        vec![package_lock, workspace_lock]
220    }
221}
222
223fn crate_git_revision(generated: &Path) -> Result<Option<String>, Box<dyn Error>> {
224    let path = generated.join(".cargo_vcs_info.json");
225    let source = match fs::read_to_string(path) {
226        Ok(source) => source,
227        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
228        Err(error) => return Err(error.into()),
229    };
230    let info: serde_json::Value = serde_json::from_str(&source)?;
231    let revision = info
232        .get("git")
233        .and_then(|git| git.get("sha1"))
234        .and_then(serde_json::Value::as_str)
235        .ok_or(".cargo_vcs_info.json does not contain git.sha1")?;
236    if revision.len() != 40 || !revision.bytes().all(|byte| byte.is_ascii_hexdigit()) {
237        return Err(format!("invalid git.sha1 in .cargo_vcs_info.json: {revision}").into());
238    }
239    Ok(Some(revision.to_ascii_lowercase()))
240}
241
242fn locked_package_checksum(
243    package_name: &str,
244    lock: &Path,
245    target_version: &str,
246) -> Result<Option<String>, Box<dyn Error>> {
247    let lock = fs::read_to_string(lock)?;
248    let mut name = None;
249    let mut version = None;
250    let mut source = None;
251    let mut checksum = None;
252
253    for line in lock.lines().chain(["[[package]]"]) {
254        if line == "[[package]]" {
255            if name.as_deref() == Some(package_name)
256                && version.as_deref() == Some(target_version)
257                && source
258                    .as_deref()
259                    .is_some_and(|source: &str| source.starts_with("registry+"))
260            {
261                return Ok(checksum);
262            }
263            name = None;
264            version = None;
265            source = None;
266            checksum = None;
267            continue;
268        }
269
270        if let Some(value) = line.strip_prefix("name = ") {
271            name = Some(value.trim_matches('"').to_owned());
272        } else if let Some(value) = line.strip_prefix("version = ") {
273            version = Some(value.trim_matches('"').to_owned());
274        } else if let Some(value) = line.strip_prefix("source = ") {
275            source = Some(value.trim_matches('"').to_owned());
276        } else if let Some(value) = line.strip_prefix("checksum = ") {
277            checksum = Some(value.trim_matches('"').to_owned());
278        }
279    }
280
281    Ok(None)
282}
283
284fn hex_digest(bytes: impl AsRef<[u8]>) -> String {
285    Sha256::digest(bytes)
286        .iter()
287        .map(|byte| format!("{byte:02x}"))
288        .collect()
289}
290
291fn unpack_crate_archive(
292    package_name: &str,
293    archive: &Path,
294    destination: &Path,
295    package: &LockedPackage,
296) -> Result<(), Box<dyn Error>> {
297    let decoder = GzDecoder::new(fs::File::open(archive)?);
298    let mut archive = tar::Archive::new(decoder);
299    let package_dir = package_dir(package_name, package);
300
301    for entry in archive.entries()? {
302        let mut entry = entry?;
303        let path = entry.path()?;
304        let relative = checked_archive_path(&path, &package_dir)?;
305        let destination_path = destination.join(relative);
306        let entry_type = entry.header().entry_type();
307
308        if entry_type.is_dir() {
309            fs::create_dir_all(&destination_path)?;
310        } else if entry_type.is_file() {
311            if let Some(parent) = destination_path.parent() {
312                fs::create_dir_all(parent)?;
313            }
314            entry.unpack(&destination_path)?;
315        } else {
316            return Err(format!("unsupported archive entry type for {}", path.display()).into());
317        }
318    }
319
320    Ok(())
321}
322
323fn rewrite_lib_header(lib_rs: &Path) -> Result<(), Box<dyn Error>> {
324    let source = fs::read_to_string(lib_rs)?;
325    let mut rewritten = String::new();
326
327    for line in source.lines() {
328        if line.starts_with("//!") || line.starts_with("#![") {
329            continue;
330        }
331
332        rewritten.push_str(line);
333        rewritten.push('\n');
334    }
335
336    fs::write(lib_rs, rewritten)?;
337    Ok(())
338}
339
340fn checked_archive_path(path: &Path, package_dir: &str) -> Result<PathBuf, Box<dyn Error>> {
341    let mut components = path.components();
342    match components.next() {
343        Some(Component::Normal(component)) if component == package_dir => {}
344        _ => return Err(format!("unexpected archive path {}", path.display()).into()),
345    }
346
347    let mut relative = PathBuf::new();
348    for component in components {
349        match component {
350            Component::Normal(part) => relative.push(part),
351            _ => return Err(format!("unsupported archive path {}", path.display()).into()),
352        }
353    }
354
355    if relative.as_os_str().is_empty() {
356        Ok(PathBuf::from("."))
357    } else {
358        Ok(relative)
359    }
360}
361
362fn verify_manifest_matches_bridge(
363    package_name: &str,
364    upstream_manifest_path: &Path,
365    bridge_manifest_path: &Path,
366) -> Result<(), Box<dyn Error>> {
367    let upstream_manifest = read_manifest(upstream_manifest_path)?;
368    let bridge_manifest = read_manifest(bridge_manifest_path)?;
369    let mut mismatches = Vec::new();
370
371    compare_manifest_section(
372        "dependencies",
373        normalized_dependencies(&upstream_manifest, None),
374        normalized_dependencies(
375            &bridge_manifest,
376            manifest_table(&upstream_manifest, &["dependencies"]),
377        ),
378        true,
379        &mut mismatches,
380    );
381    compare_manifest_section(
382        "target",
383        normalized_target_dependencies(&upstream_manifest, None),
384        normalized_target_dependencies(&bridge_manifest, Some(&upstream_manifest)),
385        true,
386        &mut mismatches,
387    );
388    compare_manifest_section(
389        "features",
390        manifest_section(&upstream_manifest, &["features"]),
391        manifest_section(&bridge_manifest, &["features"]),
392        false,
393        &mut mismatches,
394    );
395    compare_manifest_section(
396        "lints",
397        manifest_section(&upstream_manifest, &["lints"]),
398        manifest_section(&bridge_manifest, &["lints"]),
399        false,
400        &mut mismatches,
401    );
402
403    if mismatches.is_empty() {
404        Ok(())
405    } else {
406        Err(format!(
407            "bridge Cargo.toml is out of sync with {package_name}:\n{}",
408            mismatches.join("\n")
409        )
410        .into())
411    }
412}
413
414fn read_manifest(path: &Path) -> Result<Value, Box<dyn Error>> {
415    Ok(toml::from_str(&fs::read_to_string(path)?)?)
416}
417
418fn manifest_table<'a>(manifest: &'a Value, path: &[&str]) -> Option<&'a Table> {
419    let mut value = manifest;
420    for key in path {
421        value = value.get(*key)?;
422    }
423    value.as_table()
424}
425
426fn manifest_section(manifest: &Value, path: &[&str]) -> Option<Value> {
427    let mut value = manifest;
428    for key in path {
429        value = value.get(*key)?;
430    }
431    Some(value.clone())
432}
433
434fn compare_manifest_section(
435    label: &str,
436    expected: Option<Value>,
437    actual: Option<Value>,
438    allow_extra: bool,
439    mismatches: &mut Vec<String>,
440) {
441    match (expected, actual) {
442        (Some(expected), Some(actual)) if expected == actual => {}
443        (Some(Value::Table(expected)), Some(Value::Table(actual))) => {
444            compare_manifest_tables(label, &expected, &actual, allow_extra, mismatches);
445        }
446        (Some(_), Some(_)) => mismatches.push(format!("  {label}: different value")),
447        (Some(_), None) => {
448            mismatches.push(format!("  {label}: missing section in bridge manifest"))
449        }
450        (None, Some(_)) => mismatches.push(format!("  {label}: extra section in bridge manifest")),
451        (None, None) => {}
452    }
453}
454
455fn compare_manifest_tables(
456    label: &str,
457    expected: &Table,
458    actual: &Table,
459    allow_extra: bool,
460    mismatches: &mut Vec<String>,
461) {
462    let expected_only = table_keys(expected)
463        .into_iter()
464        .filter(|key| !actual.contains_key(key))
465        .collect::<Vec<_>>();
466    let actual_only = table_keys(actual)
467        .into_iter()
468        .filter(|key| !expected.contains_key(key))
469        .collect::<Vec<_>>();
470    let changed = table_keys(expected)
471        .into_iter()
472        .filter(|key| actual.get(key) != expected.get(key))
473        .collect::<Vec<_>>();
474
475    if !expected_only.is_empty() {
476        mismatches.push(format!(
477            "  {label}: missing keys in bridge manifest: {}",
478            expected_only.join(", ")
479        ));
480    }
481    if !actual_only.is_empty() && !allow_extra {
482        mismatches.push(format!(
483            "  {label}: extra keys in bridge manifest: {}",
484            actual_only.join(", ")
485        ));
486    }
487    if !changed.is_empty() {
488        mismatches.push(format!(
489            "  {label}: different values: {}",
490            changed.join(", ")
491        ));
492    }
493}
494
495fn normalized_dependencies(manifest: &Value, reference: Option<&Table>) -> Option<Value> {
496    Some(Value::Table(normalize_dependencies(
497        manifest_table(manifest, &["dependencies"])?,
498        reference,
499    )))
500}
501
502fn normalized_target_dependencies(manifest: &Value, reference: Option<&Value>) -> Option<Value> {
503    let targets = manifest_table(manifest, &["target"])?;
504    let mut normalized_targets = Map::new();
505
506    for (target, target_value) in targets {
507        if target == "cfg(any())" {
508            continue;
509        }
510        let Some(target_table) = target_value.as_table() else {
511            continue;
512        };
513        let Some(dependencies) = target_table.get("dependencies").and_then(Value::as_table) else {
514            continue;
515        };
516        let reference_dependencies = reference.and_then(|reference| {
517            manifest_table(reference, &["target", target.as_str(), "dependencies"])
518        });
519        let mut normalized_target = Map::new();
520        normalized_target.insert(
521            "dependencies".to_owned(),
522            Value::Table(normalize_dependencies(dependencies, reference_dependencies)),
523        );
524        normalized_targets.insert(target.clone(), Value::Table(normalized_target));
525    }
526
527    if normalized_targets.is_empty() {
528        None
529    } else {
530        Some(Value::Table(normalized_targets))
531    }
532}
533
534fn normalize_dependencies(dependencies: &Table, reference: Option<&Table>) -> Table {
535    dependencies
536        .iter()
537        .map(|(name, value)| {
538            (
539                name.clone(),
540                normalize_dependency(value, reference.and_then(|reference| reference.get(name))),
541            )
542        })
543        .collect()
544}
545
546fn normalize_dependency(value: &Value, reference: Option<&Value>) -> Value {
547    let Some(mut dependency) = dependency_table(value) else {
548        return value.clone();
549    };
550    let Some(reference) = reference.and_then(dependency_table) else {
551        return Value::Table(dependency);
552    };
553
554    let had_path = dependency.remove("path").is_some();
555    if had_path || is_replaced_package_dependency(&dependency, &reference) {
556        copy_dependency_field(&mut dependency, &reference, "package");
557        copy_dependency_field(&mut dependency, &reference, "version");
558    }
559
560    Value::Table(dependency)
561}
562
563fn dependency_table(value: &Value) -> Option<Table> {
564    match value {
565        Value::String(version) => {
566            let mut dependency = Map::new();
567            dependency.insert("version".to_owned(), Value::String(version.clone()));
568            Some(dependency)
569        }
570        Value::Table(dependency) => Some(dependency.clone()),
571        _ => None,
572    }
573}
574
575fn is_replaced_package_dependency(dependency: &Table, reference: &Table) -> bool {
576    dependency.get("package").is_some()
577        && dependency.get("package") != reference.get("package")
578        && dependency.get("version") != reference.get("version")
579}
580
581fn copy_dependency_field(dependency: &mut Table, reference: &Table, field: &str) {
582    if let Some(value) = reference.get(field) {
583        dependency.insert(field.to_owned(), value.clone());
584    } else {
585        dependency.remove(field);
586    }
587}
588
589fn table_keys(table: &Table) -> Vec<String> {
590    let mut keys = table.keys().cloned().collect::<Vec<_>>();
591    keys.sort();
592    keys
593}
594
595fn package_dir(package_name: &str, package: &LockedPackage) -> String {
596    format!("{package_name}-{}", package.version)
597}
598
599fn archive_name(package_name: &str, package: &LockedPackage) -> String {
600    format!("{}.crate", package_dir(package_name, package))
601}