Skip to content

feat: support trim paths with clang-cl.exe - #1799

Merged
NobodyXu merged 4 commits into
rust-lang:mainfrom
weihanglo:trim-paths
Jul 21, 2026
Merged

feat: support trim paths with clang-cl.exe#1799
NobodyXu merged 4 commits into
rust-lang:mainfrom
weihanglo:trim-paths

Conversation

@weihanglo

Copy link
Copy Markdown
Member

Summary

A follow-up of #1794

MSVC-family compilers were skipped before this, including clang-cl.exe,
which actually accepts Clang driver options through /clang:<arg>.

This PR enables the existing remap flags for clang-cl via /clang:<arg>.

The behavior remains unchanged for cl.exe.

Do note this only remaps fields covered by Clang's prefix-map options.
It does not mean every absolute path disappears from Windows artifacts.

So far as I tested it,
the final .exe no longer exposes the checkout path,
though it does not make the Windows artifacts byte-reproducible.

How to review

Commit by commit.

  • The first one lets the test shim record nested MSVC flag probes.
  • The second makes the existing remap test data work with the Windows path-list separator.
  • The third captures the current cl.exe and clang-cl.exe behavior.
  • The last one is the feature commit that probes and forwards the Clang flags for clang-cl.exe.

How to test

I used the LLVM tools installed with Visual Studio:

$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"
$hostTriple = rustc +nightly --print host-tuple
$llvmArch = switch -Regex ($hostTriple) {
    '^aarch64-' { 'ARM64'; break }
    '^(x86_64|i686)-' { 'x64'; break }
    default { throw "unsupported host: $hostTriple" }
}
$clangCl = & $vswhere -latest -products * -find "**\Llvm\$llvmArch\bin\clang-cl.exe" |
    Select-Object -First 1
if (!$clangCl) {
    throw "clang-cl.exe was not found"
}
& $clangCl --version
$env:CC = $clangCl
cargo +nightly -Zscript .\trim-paths-repro.rs

Tested with clang-cl 19.1.5.

repro script

#!/usr/bin/env -S cargo +nightly -Zscript
---
[package]
edition = "2024"
---

//! End-to-end repro: cargo `-Ztrim-paths` -> cc -> clang-cl.
//!
//! Put this under the cc-rs repo root and run it with `CC` set to clang-cl.
//!
//! - `__FILE__` carrier: absolute `.file()` + `debug = false`
//!   -> trimmed by `-fmacro-prefix-map` (scopes `macro`, `object`, `all`)
//! - CodeView carrier: relative `.file()` + `debug = true`
//!   (debug info embeds the source path)
//!   -> trimmed by `-fdebug-prefix-map` (scopes `object`, `all`);
//!   `macro` must leave it EMBEDDED, mirroring rustc scope semantics.

use std::path::{Path, PathBuf};
use std::process::Command;
use std::{env, fs};

fn main() {
    let cc_root = env::current_dir().unwrap();
    assert!(
        fs::read_to_string("Cargo.toml")
            .unwrap_or_default()
            .contains("name = \"cc\""),
        "run this from the cc-rs repo root"
    );

    let work = env::temp_dir().join(format!("cc-trim-e2e-{}", std::process::id()));
    let _ = fs::remove_dir_all(&work);
    fs::create_dir_all(&work).unwrap();
    // Keep the normal drive-letter spelling. `canonicalize()` produces a
    // `\\?\` path on Windows, while Cargo's remap source uses `C:\...`.
    // Prefix-map matching is textual.
    let abs = work.display().to_string();

    eprintln!("work directory: {}", work.display());
    let mut ok = true;
    for debug in [false, true] {
        eprintln!("== {} carrier ==", if debug { "CodeView" } else { "__FILE__" });
        for scope in ["all", "object", "macro", "none"] {
            // `none` trims nothing; `macro` does not touch debug info.
            let expect = scope == "none" || (debug && scope == "macro");
            let object = build(&cc_root, &work, debug, scope);
            let found = if debug {
                codeview_filenames_contain(&object, &abs)
            } else {
                contains(&fs::read(object).unwrap(), abs.as_bytes())
            };
            let emb = |b| if b { "EMBEDDED" } else { "absent" };
            eprintln!(
                "trim-paths = {scope:8}: absolute path {:8} (expected {})",
                emb(found),
                emb(expect)
            );
            if found != expect {
                ok = false;
            }
        }
    }

    if !ok {
        eprintln!(
            "\nFAIL (does this cc checkout support clang-cl trim-paths,\n\
             and does this cargo set CARGO_TRIM_PATHS_* for build scripts?)"
        );
        eprintln!("build files retained under {}", work.display());
        std::process::exit(1);
    }
    let _ = fs::remove_dir_all(&work);
    eprintln!("\nOK: cc inherited cargo's trim-paths remap end to end");
}

/// Scaffold a package
///
/// * `build.rs` compiles `hello.c` through this cc checkout
/// * `cargo build`
/// * return the compiled object path
fn build(cc_root: &Path, work: &Path, debug: bool, scope: &str) -> PathBuf {
    let pkg = work.join(format!("pkg-{}-{scope}", u8::from(debug)));
    fs::create_dir_all(pkg.join("src")).unwrap();
    // Absolute `.file()` makes `__FILE__` carry the absolute path; a
    // relative one leaves only CodeView carrying an absolute path.
    let src = if debug {
        PathBuf::from("src/hello.c")
    } else {
        pkg.join("src/hello.c")
    };
    fs::write(
        pkg.join("Cargo.toml"),
        format!(
            r#"cargo-features = ["trim-paths"]

[package]
name = "trim-repro"
edition = "2021"

[build-dependencies]
cc = {{ path = '{}' }}

[profile.dev]
trim-paths = "{scope}"
debug = {debug}
"#,
            cc_root.display()
        ),
    )
    .unwrap();
    fs::write(
        pkg.join("build.rs"),
        format!(
            "fn main() {{ cc::Build::new().file(r\"{}\").compile(\"hello\"); }}\n",
            src.display()
        ),
    )
    .unwrap();
    fs::write(pkg.join("src/lib.rs"), "").unwrap();
    fs::write(
        pkg.join("src/hello.c"),
        "const char *embedded = __FILE__;\nconst char *get(void) { return embedded; }\n",
    )
    .unwrap();

    let target_dir = pkg.join("target");
    let out = Command::new("cargo")
        .args(["+nightly", "build", "-Ztrim-paths", "--target-dir"])
        .arg(&target_dir)
        .current_dir(&pkg)
        .output()
        .unwrap();
    assert!(
        out.status.success(),
        "cargo build failed:\n{}",
        String::from_utf8_lossy(&out.stderr)
    );

    fs::read_dir(target_dir.join("debug/build"))
        .unwrap()
        .find_map(|e| {
            fs::read_dir(e.ok()?.path().join("out"))
                .ok()?
                .find_map(|e| {
                    let object = e.ok()?.path();
                    object
                        .file_name()?
                        .to_string_lossy()
                        .ends_with("-hello.o")
                        .then_some(object)
                })
        })
        .expect("no hello object produced")
}

fn codeview_filenames_contain(object: &Path, needle: &str) -> bool {
    let mut readobj = PathBuf::from(env::var_os("CC").expect("CC must name clang-cl"));
    readobj.set_file_name("llvm-readobj.exe");
    let out = Command::new(readobj)
        .arg("--codeview")
        .arg(object)
        .output()
        .unwrap();
    assert!(
        out.status.success(),
        "llvm-readobj failed:\n{}",
        String::from_utf8_lossy(&out.stderr)
    );
    String::from_utf8_lossy(&out.stdout)
        .lines()
        .filter(|line| line.trim_start().starts_with("Filename:"))
        .any(|line| line.contains(needle))
}

fn contains(hay: &[u8], needle: &[u8]) -> bool {
    hay.windows(needle.len()).any(|w| w == needle)
}

MSVC-family compilers were skipped, including `clang-cl.exe`,
which actually accepts Clang driver options through `/clang:<arg>`.

This enables the existing remap flags for clang-cl via `/clang:<arg>`.

The behavior remains unchanged for `cl.exe`.

Do note this only remaps fields covered by Clang's prefix-map options.
It does not mean every absolute path disappears from Windows artifacts.

* A debug `.obj` can still contain its absolute output name in CodeView
  `S_OBJNAME` somehow, and the recorded clang command line still contains
  the old side of the remap pairs.
* A separate PDB still contains linker module/object paths.
  Cargo also says `object` does not sanitize debug info stored separately on Windows MSVC.
  <https://doc.rust-lang.org/nightly/cargo/reference/unstable.html#profile-trim-paths-option>
* The `.exe` stores the PDB filename and GUID.
  Two builds can have no checkout path in the executable
  and still not be byte-identical when the PDB GUID changes.
  <https://llvm.org/docs/PDB/PdbStream.html#matching-a-pdb-to-its-executable>

So far as I tested it,
the final `.exe` no longer exposes the checkout path,
though it does not make the Windows artifacts byte-reproducible.

### How to review

Commit by commit.

* The first one lets the test shim record nested MSVC flag probes.
* The second makes the existing remap test data work with the Windows path-list separator.
* The third captures the current `cl.exe` and `clang-cl.exe` behavior.
* The last one is the feature commit that probes and forwards the Clang flags for `clang-cl.exe`.

### How to test

I used the LLVM tools installed with Visual Studio:

```powershell
$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"
$hostTriple = rustc +nightly --print host-tuple
$llvmArch = switch -Regex ($hostTriple) {
    '^aarch64-' { 'ARM64'; break }
    '^(x86_64|i686)-' { 'x64'; break }
    default { throw "unsupported host: $hostTriple" }
}
$clangCl = & $vswhere -latest -products * -find "**\Llvm\$llvmArch\bin\clang-cl.exe" |
    Select-Object -First 1
if (!$clangCl) {
    throw "clang-cl.exe was not found"
}
& $clangCl --version
$env:CC = $clangCl
cargo +nightly -Zscript .\trim-paths-repro.rs
```

Tested with clang-cl 19.1.5.

<details><summary>repro script</summary>
<p>

```rust
#!/usr/bin/env -S cargo +nightly -Zscript
---
[package]
edition = "2024"
---

//! End-to-end repro: cargo `-Ztrim-paths` -> cc -> clang-cl.
//!
//! Put this under the cc-rs repo root and run it with `CC` set to clang-cl.
//!
//! - `__FILE__` carrier: absolute `.file()` + `debug = false`
//!   -> trimmed by `-fmacro-prefix-map` (scopes `macro`, `object`, `all`)
//! - CodeView carrier: relative `.file()` + `debug = true`
//!   (debug info embeds the source path)
//!   -> trimmed by `-fdebug-prefix-map` (scopes `object`, `all`);
//!   `macro` must leave it EMBEDDED, mirroring rustc scope semantics.

use std::path::{Path, PathBuf};
use std::process::Command;
use std::{env, fs};

fn main() {
    let cc_root = env::current_dir().unwrap();
    assert!(
        fs::read_to_string("Cargo.toml")
            .unwrap_or_default()
            .contains("name = \"cc\""),
        "run this from the cc-rs repo root"
    );

    let work = env::temp_dir().join(format!("cc-trim-e2e-{}", std::process::id()));
    let _ = fs::remove_dir_all(&work);
    fs::create_dir_all(&work).unwrap();
    // Keep the normal drive-letter spelling. `canonicalize()` produces a
    // `\\?\` path on Windows, while Cargo's remap source uses `C:\...`.
    // Prefix-map matching is textual.
    let abs = work.display().to_string();

    eprintln!("work directory: {}", work.display());
    let mut ok = true;
    for debug in [false, true] {
        eprintln!("== {} carrier ==", if debug { "CodeView" } else { "__FILE__" });
        for scope in ["all", "object", "macro", "none"] {
            // `none` trims nothing; `macro` does not touch debug info.
            let expect = scope == "none" || (debug && scope == "macro");
            let object = build(&cc_root, &work, debug, scope);
            let found = if debug {
                codeview_filenames_contain(&object, &abs)
            } else {
                contains(&fs::read(object).unwrap(), abs.as_bytes())
            };
            let emb = |b| if b { "EMBEDDED" } else { "absent" };
            eprintln!(
                "trim-paths = {scope:8}: absolute path {:8} (expected {})",
                emb(found),
                emb(expect)
            );
            if found != expect {
                ok = false;
            }
        }
    }

    if !ok {
        eprintln!(
            "\nFAIL (does this cc checkout support clang-cl trim-paths,\n\
             and does this cargo set CARGO_TRIM_PATHS_* for build scripts?)"
        );
        eprintln!("build files retained under {}", work.display());
        std::process::exit(1);
    }
    let _ = fs::remove_dir_all(&work);
    eprintln!("\nOK: cc inherited cargo's trim-paths remap end to end");
}

/// Scaffold a package
///
/// * `build.rs` compiles `hello.c` through this cc checkout
/// * `cargo build`
/// * return the compiled object path
fn build(cc_root: &Path, work: &Path, debug: bool, scope: &str) -> PathBuf {
    let pkg = work.join(format!("pkg-{}-{scope}", u8::from(debug)));
    fs::create_dir_all(pkg.join("src")).unwrap();
    // Absolute `.file()` makes `__FILE__` carry the absolute path; a
    // relative one leaves only CodeView carrying an absolute path.
    let src = if debug {
        PathBuf::from("src/hello.c")
    } else {
        pkg.join("src/hello.c")
    };
    fs::write(
        pkg.join("Cargo.toml"),
        format!(
            r#"cargo-features = ["trim-paths"]

[package]
name = "trim-repro"
edition = "2021"

[build-dependencies]
cc = {{ path = '{}' }}

[profile.dev]
trim-paths = "{scope}"
debug = {debug}
"#,
            cc_root.display()
        ),
    )
    .unwrap();
    fs::write(
        pkg.join("build.rs"),
        format!(
            "fn main() {{ cc::Build::new().file(r\"{}\").compile(\"hello\"); }}\n",
            src.display()
        ),
    )
    .unwrap();
    fs::write(pkg.join("src/lib.rs"), "").unwrap();
    fs::write(
        pkg.join("src/hello.c"),
        "const char *embedded = __FILE__;\nconst char *get(void) { return embedded; }\n",
    )
    .unwrap();

    let target_dir = pkg.join("target");
    let out = Command::new("cargo")
        .args(["+nightly", "build", "-Ztrim-paths", "--target-dir"])
        .arg(&target_dir)
        .current_dir(&pkg)
        .output()
        .unwrap();
    assert!(
        out.status.success(),
        "cargo build failed:\n{}",
        String::from_utf8_lossy(&out.stderr)
    );

    fs::read_dir(target_dir.join("debug/build"))
        .unwrap()
        .find_map(|e| {
            fs::read_dir(e.ok()?.path().join("out"))
                .ok()?
                .find_map(|e| {
                    let object = e.ok()?.path();
                    object
                        .file_name()?
                        .to_string_lossy()
                        .ends_with("-hello.o")
                        .then_some(object)
                })
        })
        .expect("no hello object produced")
}

fn codeview_filenames_contain(object: &Path, needle: &str) -> bool {
    let mut readobj = PathBuf::from(env::var_os("CC").expect("CC must name clang-cl"));
    readobj.set_file_name("llvm-readobj.exe");
    let out = Command::new(readobj)
        .arg("--codeview")
        .arg(object)
        .output()
        .unwrap();
    assert!(
        out.status.success(),
        "llvm-readobj failed:\n{}",
        String::from_utf8_lossy(&out.stderr)
    );
    String::from_utf8_lossy(&out.stdout)
        .lines()
        .filter(|line| line.trim_start().starts_with("Filename:"))
        .any(|line| line.contains(needle))
}

fn contains(hay: &[u8], needle: &[u8]) -> bool {
    hay.windows(needle.len()).any(|w| w == needle)
}
```

</p>
</details>

@NobodyXu NobodyXu left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, LGTM! Next release scheduled for this Friday

@NobodyXu
NobodyXu merged commit 3dcd9ce into rust-lang:main Jul 21, 2026
79 checks passed
@weihanglo

Copy link
Copy Markdown
Member Author

Thank you for the review and the release! 🫶🏾

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants