Skip to content

Commit ff129fd

Browse files
committed
feat: new source PatchedSource
1 parent 88a876f commit ff129fd

File tree

4 files changed

+359
-1
lines changed

4 files changed

+359
-1
lines changed

src/cargo/core/source_id.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use crate::core::GitReference;
22
use crate::core::PackageId;
33
use crate::core::SourceKind;
4+
use crate::sources::patched::PatchedSource;
45
use crate::sources::registry::CRATES_IO_HTTP_INDEX;
56
use crate::sources::source::Source;
67
use crate::sources::{DirectorySource, CRATES_IO_DOMAIN, CRATES_IO_INDEX, CRATES_IO_REGISTRY};
@@ -438,7 +439,7 @@ impl SourceId {
438439
.expect("path sources cannot be remote");
439440
Ok(Box::new(DirectorySource::new(&path, self, gctx)))
440441
}
441-
SourceKind::Patched(_) => todo!(),
442+
SourceKind::Patched(_) => Ok(Box::new(PatchedSource::new(self, gctx)?)),
442443
}
443444
}
444445

src/cargo/sources/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ pub use self::replaced::ReplacedSource;
3838
pub mod config;
3939
pub mod directory;
4040
pub mod git;
41+
pub mod patched;
4142
pub mod path;
4243
pub mod registry;
4344
pub mod replaced;

src/cargo/sources/patched.rs

Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,351 @@
1+
//! A source that takes other source and patches it with local patch files.
2+
//! See [`PatchedSource`] for details.
3+
4+
use std::path::Path;
5+
use std::path::PathBuf;
6+
use std::task::Poll;
7+
8+
use anyhow::Context as _;
9+
use cargo_util::paths;
10+
use cargo_util::ProcessBuilder;
11+
use cargo_util::Sha256;
12+
use cargo_util_schemas::core::PatchInfo;
13+
use cargo_util_schemas::core::SourceKind;
14+
use lazycell::LazyCell;
15+
16+
use crate::core::Dependency;
17+
use crate::core::Package;
18+
use crate::core::PackageId;
19+
use crate::core::SourceId;
20+
use crate::core::Verbosity;
21+
use crate::sources::source::MaybePackage;
22+
use crate::sources::source::QueryKind;
23+
use crate::sources::source::Source;
24+
use crate::sources::IndexSummary;
25+
use crate::sources::PathSource;
26+
use crate::sources::SourceConfigMap;
27+
use crate::util::cache_lock::CacheLockMode;
28+
use crate::util::hex;
29+
use crate::util::OptVersionReq;
30+
use crate::CargoResult;
31+
use crate::GlobalContext;
32+
33+
/// A file indicates that if present, the patched source is ready to use.
34+
const READY_LOCK: &str = ".cargo-ok";
35+
36+
/// `PatchedSource` is a source that, when fetching, it patches a paticular
37+
/// package with given local patch files.
38+
///
39+
/// This could only be created from [the `[patch]` section][patch] with any
40+
/// entry carrying `{ .., patches = ["..."] }` field. Other kinds of dependency
41+
/// sections (normal, dev, build) shouldn't allow to create any `PatchedSource`.
42+
///
43+
/// [patch]: https://doc.rust-lang.org/nightly/cargo/reference/overriding-dependencies.html#the-patch-section
44+
///
45+
/// ## Filesystem layout
46+
///
47+
/// When Cargo fetches a package from a `PatchedSource`, it'll copy everything
48+
/// from the original source to a dedicated patched source directory. That
49+
/// directory is located under `$CARGO_HOME`. The patched source of each package
50+
/// would be put under:
51+
///
52+
/// ```text
53+
/// $CARGO_HOME/patched-src/<hash-of-original-source>/<pkg>-<version>/<cksum-of-patches>/`.
54+
/// ```
55+
///
56+
/// The file tree of the patched source directory roughly looks like:
57+
///
58+
/// ```text
59+
/// $CARGO_HOME/patched-src/github.com-6d038ece37e82ae2
60+
/// ├── gimli-0.29.0/
61+
/// │ ├── a0d193bd15a5ed96/ # checksum of all patch files from a patch to [email protected]
62+
/// │ ├── c58e1db3de7c154d/
63+
/// └── serde-1.0.197/
64+
/// └── deadbeef12345678/
65+
/// ```
66+
///
67+
/// ## `SourceId` for tracking the original package
68+
///
69+
/// Due to the nature that a patched source is actually locked to a specific
70+
/// version of one package, the SourceId URL of a `PatchedSource` needs to
71+
/// carry such information. It looks like:
72+
///
73+
/// ```text
74+
/// patched+registry+https://github.com/rust-lang/crates.io-index?name=foo&version=1.0.0&patch=0001-bugfix.patch
75+
/// ```
76+
///
77+
/// where the `patched+` protocol is essential for Cargo to distinguish between
78+
/// a patched source and the source it patches. The query string contains the
79+
/// name and version of the package being patched. We want patches to be as
80+
/// reproducible as it could, so lock to one specific version here.
81+
/// See [`PatchInfo::from_query`] to learn what are being tracked.
82+
///
83+
/// To achieve it, the version specified in any of the entry in `[patch]` must
84+
/// be an exact version via the `=` SemVer comparsion operator. For example,
85+
/// this will fetch source of [email protected] from crates.io, and apply patches to it.
86+
///
87+
/// ```toml
88+
/// [patch.crates-io]
89+
/// serde = { version = "=1.2.3", patches = ["patches/0001-serde-bug.patch"] }
90+
/// ```
91+
///
92+
/// ## Patch tools
93+
///
94+
/// When patching a package, Cargo will change the working directory to
95+
/// the root directory of the copied source code, and then execute the tool
96+
/// specified via the `patchtool.path` config value in the Cargo configuration.
97+
/// Paths of patch files will be provided as absolute paths to the tool.
98+
pub struct PatchedSource<'gctx> {
99+
source_id: SourceId,
100+
/// The source of the package we're going to patch.
101+
original_source: Box<dyn Source + 'gctx>,
102+
/// Checksum from all patch files.
103+
patches_checksum: LazyCell<String>,
104+
/// For respecting `[source]` replacement configuration.
105+
map: SourceConfigMap<'gctx>,
106+
path_source: Option<PathSource<'gctx>>,
107+
quiet: bool,
108+
gctx: &'gctx GlobalContext,
109+
}
110+
111+
impl<'gctx> PatchedSource<'gctx> {
112+
pub fn new(
113+
source_id: SourceId,
114+
gctx: &'gctx GlobalContext,
115+
) -> CargoResult<PatchedSource<'gctx>> {
116+
let original_id = {
117+
let mut url = source_id.url().clone();
118+
url.set_query(None);
119+
url.set_fragment(None);
120+
let url = url.as_str();
121+
let Some(url) = url.strip_prefix("patched+") else {
122+
anyhow::bail!("patched source url requires a `patched` scheme, got `{url}`");
123+
};
124+
SourceId::from_url(&url)?
125+
};
126+
let map = SourceConfigMap::new(gctx)?;
127+
let source = PatchedSource {
128+
source_id,
129+
original_source: map.load(original_id, &Default::default())?,
130+
patches_checksum: LazyCell::new(),
131+
map,
132+
path_source: None,
133+
quiet: false,
134+
gctx,
135+
};
136+
Ok(source)
137+
}
138+
139+
/// Downloads the package source if needed.
140+
fn download_pkg(&mut self) -> CargoResult<Package> {
141+
let patch_info = self.patch_info();
142+
let exact_req = &format!("={}", patch_info.version());
143+
let original_id = self.original_source.source_id();
144+
let dep = Dependency::parse(patch_info.name(), Some(exact_req), original_id)?;
145+
let pkg_id = loop {
146+
match self.original_source.query_vec(&dep, QueryKind::Exact) {
147+
Poll::Ready(deps) => break deps?.remove(0).as_summary().package_id(),
148+
Poll::Pending => self.original_source.block_until_ready()?,
149+
}
150+
};
151+
152+
let source = self.map.load(original_id, &Default::default())?;
153+
Box::new(source).download_now(pkg_id, self.gctx)
154+
}
155+
156+
fn copy_pkg_src(&self, pkg: &Package, dst: &Path) -> CargoResult<()> {
157+
let src = pkg.root();
158+
for entry in walkdir::WalkDir::new(src) {
159+
let entry = entry?;
160+
let path = entry.path().strip_prefix(src).unwrap();
161+
let src = entry.path();
162+
let dst = dst.join(path);
163+
if entry.file_type().is_dir() {
164+
paths::create_dir_all(dst)?;
165+
} else {
166+
// TODO: handle symlink?
167+
paths::copy(src, dst)?;
168+
}
169+
}
170+
Ok(())
171+
}
172+
173+
fn apply_patches(&self, pkg: &Package, dst: &Path) -> CargoResult<()> {
174+
let patches = self.patch_info().patches();
175+
let n = patches.len();
176+
assert!(n > 0, "must have at least one patch, got {n}");
177+
178+
self.gctx.shell().status("Patching", pkg)?;
179+
180+
let patchtool_config = self.gctx.patchtool_config()?;
181+
let Some(tool) = patchtool_config.path.as_ref() else {
182+
anyhow::bail!("missing `[patchtool]` for patching dependencies");
183+
};
184+
185+
let program = tool.path.resolve_program(self.gctx);
186+
let mut cmd = ProcessBuilder::new(program);
187+
cmd.cwd(dst).args(&tool.args);
188+
189+
for patch_path in patches {
190+
let patch_path = self.gctx.cwd().join(patch_path);
191+
let mut cmd = cmd.clone();
192+
cmd.arg(patch_path);
193+
if matches!(self.gctx.shell().verbosity(), Verbosity::Verbose) {
194+
self.gctx.shell().status("Running", &cmd)?;
195+
cmd.exec()?;
196+
} else {
197+
cmd.exec_with_output()?;
198+
}
199+
}
200+
201+
Ok(())
202+
}
203+
204+
/// Gets the destination directory we put the patched source at.
205+
fn dest_src_dir(&self, pkg: &Package) -> CargoResult<PathBuf> {
206+
let patched_src_root = self.gctx.patched_source_path();
207+
let patched_src_root = self
208+
.gctx
209+
.assert_package_cache_locked(CacheLockMode::DownloadExclusive, &patched_src_root);
210+
let pkg_id = pkg.package_id();
211+
let source_id = pkg_id.source_id();
212+
let ident = source_id.url().host_str().unwrap_or_default();
213+
let hash = hex::short_hash(&source_id);
214+
let name = pkg_id.name();
215+
let version = pkg_id.version();
216+
let mut dst = patched_src_root.join(format!("{ident}-{hash}"));
217+
dst.push(format!("{name}-{version}"));
218+
dst.push(self.patches_checksum()?);
219+
Ok(dst)
220+
}
221+
222+
fn patches_checksum(&self) -> CargoResult<&String> {
223+
self.patches_checksum.try_borrow_with(|| {
224+
let mut cksum = Sha256::new();
225+
for patch in self.patch_info().patches() {
226+
cksum.update_path(patch)?;
227+
}
228+
let mut cksum = cksum.finish_hex();
229+
// TODO: is it safe to truncate sha256?
230+
cksum.truncate(16);
231+
Ok(cksum)
232+
})
233+
}
234+
235+
fn patch_info(&self) -> &PatchInfo {
236+
let SourceKind::Patched(info) = self.source_id.kind() else {
237+
panic!("patched source must be SourceKind::Patched");
238+
};
239+
info
240+
}
241+
}
242+
243+
impl<'gctx> Source for PatchedSource<'gctx> {
244+
fn source_id(&self) -> SourceId {
245+
self.source_id
246+
}
247+
248+
fn supports_checksums(&self) -> bool {
249+
false
250+
}
251+
252+
fn requires_precise(&self) -> bool {
253+
false
254+
}
255+
256+
fn query(
257+
&mut self,
258+
dep: &Dependency,
259+
kind: QueryKind,
260+
f: &mut dyn FnMut(IndexSummary),
261+
) -> Poll<CargoResult<()>> {
262+
// Version requirement here is still the `=` exact one for fetching
263+
// the source to patch, so switch it to a wildchard requirement.
264+
// It is safe because this source contains one and the only package.
265+
let mut dep = dep.clone();
266+
dep.set_version_req(OptVersionReq::Any);
267+
if let Some(src) = self.path_source.as_mut() {
268+
src.query(&dep, kind, f)
269+
} else {
270+
Poll::Pending
271+
}
272+
}
273+
274+
fn invalidate_cache(&mut self) {
275+
// No cache for a patched source
276+
}
277+
278+
fn set_quiet(&mut self, quiet: bool) {
279+
self.quiet = quiet;
280+
}
281+
282+
fn download(&mut self, id: PackageId) -> CargoResult<MaybePackage> {
283+
self.path_source
284+
.as_mut()
285+
.expect("path source must exist")
286+
.download(id)
287+
}
288+
289+
fn finish_download(&mut self, _pkg_id: PackageId, _contents: Vec<u8>) -> CargoResult<Package> {
290+
panic!("no download should have started")
291+
}
292+
293+
fn fingerprint(&self, pkg: &Package) -> CargoResult<String> {
294+
let fingerprint = self.original_source.fingerprint(pkg)?;
295+
let cksum = self.patches_checksum()?;
296+
Ok(format!("{fingerprint}/{cksum}"))
297+
}
298+
299+
fn describe(&self) -> String {
300+
use std::fmt::Write as _;
301+
let mut desc = self.original_source.describe();
302+
let n_patches = self.patch_info().patches().len();
303+
write!(desc, "with {n_patches} patch file").unwrap();
304+
if n_patches > 1 {
305+
write!(desc, "s").unwrap();
306+
}
307+
desc
308+
}
309+
310+
fn add_to_yanked_whitelist(&mut self, _pkgs: &[PackageId]) {
311+
// There is no yanked package for a patched source
312+
}
313+
314+
fn is_yanked(&mut self, _pkg: PackageId) -> Poll<CargoResult<bool>> {
315+
// There is no yanked package for a patched source
316+
Poll::Ready(Ok(false))
317+
}
318+
319+
fn block_until_ready(&mut self) -> CargoResult<()> {
320+
if self.path_source.is_some() {
321+
return Ok(());
322+
}
323+
324+
let pkg = self.download_pkg().context("failed to download source")?;
325+
let dst = self.dest_src_dir(&pkg)?;
326+
327+
let ready_lock = dst.join(READY_LOCK);
328+
let cksum = self.patches_checksum()?;
329+
match paths::read(&ready_lock) {
330+
Ok(prev_cksum) if &prev_cksum == cksum => {
331+
// We've applied patches. Assume they never change.
332+
}
333+
_ => {
334+
// Either we were interrupted, or never get started.
335+
// We just start over here.
336+
if let Err(e) = paths::remove_dir_all(&dst) {
337+
tracing::trace!("failed to remove `{}`: {e}", dst.display());
338+
}
339+
self.copy_pkg_src(&pkg, &dst)
340+
.context("failed to copy source")?;
341+
self.apply_patches(&pkg, &dst)
342+
.context("failed to apply patches")?;
343+
paths::write(&ready_lock, cksum)?;
344+
}
345+
}
346+
347+
self.path_source = Some(PathSource::new(&dst, self.source_id, self.gctx));
348+
349+
Ok(())
350+
}
351+
}

src/cargo/util/context/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,11 @@ impl GlobalContext {
402402
self.registry_base_path().join("src")
403403
}
404404

405+
/// Gets the directory containg patched package sources (`<cargo_home>/patched-src`).
406+
pub fn patched_source_path(&self) -> Filesystem {
407+
self.home_path.join("patched-src")
408+
}
409+
405410
/// Gets the default Cargo registry.
406411
pub fn default_registry(&self) -> CargoResult<Option<String>> {
407412
Ok(self

0 commit comments

Comments
 (0)