1use crate::error::{Error, Result};
26use crate::pathutil::{find_shortest_path_with, normalize_path};
27use serde_json::Value;
28use std::path::{Path, PathBuf};
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum Strategy {
32 Symlink,
33 Mirror,
34}
35
36pub fn strategy(transport_options: Option<&Value>) -> Strategy {
39 let mut current = Strategy::Symlink;
40 if std::env::var("COMPOSER_MIRROR_PATH_REPOS").is_ok_and(|v| !v.is_empty() && v != "0") {
41 current = Strategy::Mirror;
42 }
43 match transport_options.and_then(|t| t.get("symlink")) {
44 Some(Value::Bool(true)) => current = Strategy::Symlink,
45 Some(Value::Bool(false)) => current = Strategy::Mirror,
46 _ => {}
47 }
48 current
49}
50
51fn relative(transport_options: Option<&Value>) -> bool {
54 match transport_options.and_then(|t| t.get("relative")) {
55 Some(v) => v == &Value::Bool(true),
56 None => true,
57 }
58}
59
60fn realpath(p: &Path) -> Option<PathBuf> {
61 std::fs::canonicalize(p).ok()
62}
63
64pub fn install_appendix(
68 project_dir: &Path,
69 install_path: &Path,
70 dist_url: &str,
71 transport_options: Option<&Value>,
72) -> Result<String> {
73 let real_url = realpath(&project_dir.join(dist_url))
74 .ok_or_else(|| Error::Refused(format!("Failed to realpath {dist_url}")))?;
75 if realpath(install_path).as_deref() == Some(real_url.as_path()) {
76 return Ok(": Source already present".to_owned());
77 }
78 Ok(match strategy(transport_options) {
79 Strategy::Symlink => format!(": Symlinking from {dist_url}"),
80 Strategy::Mirror => format!(": Mirroring from {dist_url}"),
81 })
82}
83
84pub fn check_not_inside_source(
86 project_dir: &Path,
87 install_path: &Path,
88 dist_url: &str,
89 package_name: &str,
90) -> Result<()> {
91 let real_url = realpath(&project_dir.join(dist_url))
92 .filter(|p| p.is_dir())
93 .ok_or_else(|| {
94 Error::Refused(format!(
95 "Source path \"{dist_url}\" is not found for package {package_name}"
96 ))
97 })?;
98 let Some(real_path) = realpath(install_path) else {
99 return Ok(());
100 };
101 if real_path == real_url {
102 return Ok(());
103 }
104 let inside =
105 format!("{}/", real_path.display()).starts_with(&format!("{}/", real_url.display()));
106 if inside {
107 return Err(Error::Refused(format!(
108 "Package {package_name} cannot install to \"{}\" inside its source at \"{}\"",
109 real_path.display(),
110 real_url.display()
111 )));
112 }
113 Ok(())
114}
115
116pub fn install(
120 project_dir: &Path,
121 install_path: &Path,
122 dist_url: &str,
123 transport_options: Option<&Value>,
124) -> Result<()> {
125 let real_url = realpath(&project_dir.join(dist_url))
126 .ok_or_else(|| Error::Refused(format!("Failed to realpath {dist_url}")))?;
127 if realpath(install_path).as_deref() == Some(real_url.as_path()) {
128 return Ok(());
129 }
130 remove_path(install_path)?;
131 match strategy(transport_options) {
132 Strategy::Symlink => {
133 let target = if relative(transport_options) {
137 let cwd = realpath(project_dir).unwrap_or_else(|| project_dir.to_path_buf());
140 let absolute = match install_path.strip_prefix(project_dir) {
141 Ok(rel) => format!("{}/{}", cwd.display(), rel.display()),
142 Err(_) => install_path.to_string_lossy().into_owned(),
143 };
144 find_shortest_path_with(&absolute, &real_url.to_string_lossy(), false, true)
145 } else {
146 real_url.to_string_lossy().into_owned()
147 };
148 if let Some(parent) = install_path.parent() {
149 std::fs::create_dir_all(parent).map_err(Error::io(parent))?;
150 }
151 symlink(Path::new(&format!("{target}/")), install_path)?;
152 }
153 Strategy::Mirror => {
154 let real_url = PathBuf::from(normalize_path(&real_url.to_string_lossy()));
155 mirror(&real_url, install_path)?;
156 }
157 }
158 Ok(())
159}
160
161#[cfg(unix)]
162fn symlink(target: &Path, link: &Path) -> Result<()> {
163 std::os::unix::fs::symlink(target, link).map_err(Error::io(link))
164}
165
166#[cfg(not(unix))]
167fn symlink(_target: &Path, link: &Path) -> Result<()> {
168 Err(Error::Unsupported(format!(
169 "path repositories are not installed natively on this platform ({})",
170 link.display()
171 )))
172}
173
174pub fn remove_path(path: &Path) -> Result<()> {
177 match std::fs::symlink_metadata(path) {
178 Ok(m) if m.file_type().is_symlink() => std::fs::remove_file(path).map_err(Error::io(path)),
179 Ok(m) if m.is_dir() => std::fs::remove_dir_all(path).map_err(Error::io(path)),
180 Ok(_) => std::fs::remove_file(path).map_err(Error::io(path)),
181 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
182 Err(e) => Err(Error::io(path)(e)),
183 }
184}
185
186pub fn is_own_source(project_dir: &Path, install_path: &str, dist_url: &str) -> bool {
189 let abs = |p: &str| {
190 if crate::pathutil::is_absolute_path(p) {
191 normalize_path(p)
192 } else {
193 normalize_path(&format!("{}/{p}", project_dir.display()))
194 }
195 };
196 abs(install_path) == abs(dist_url)
197}
198
199const VCS_DIRS: &[&str] = &[
203 ".svn",
204 "_svn",
205 "CVS",
206 "_darcs",
207 ".arch-params",
208 ".monotone",
209 ".bzr",
210 ".git",
211 ".hg",
212];
213
214struct ExcludePattern {
215 regex: pcre2::bytes::Regex,
216 negate: bool,
217}
218
219fn git_exclude_patterns(source: &Path) -> Vec<ExcludePattern> {
221 let Ok(text) = std::fs::read_to_string(source.join(".gitattributes")) else {
222 return Vec::new();
223 };
224 let mut out = Vec::new();
225 for line in text.lines() {
226 let line = line.trim();
227 if line.is_empty() || line.starts_with('#') {
228 continue;
229 }
230 let parts: Vec<&str> = line.split_whitespace().collect();
231 let rule = match parts.as_slice() {
232 [p, "export-ignore"] => (*p).to_owned(),
233 [p, "-export-ignore"] => format!("!{p}"),
234 _ => continue,
235 };
236 if let Some(p) = generate_pattern(&rule) {
237 out.push(p);
238 }
239 }
240 out
241}
242
243fn generate_pattern(rule: &str) -> Option<ExcludePattern> {
245 let (negate, rule) = match rule.strip_prefix('!') {
246 Some(r) => (true, r.trim_start_matches('!')),
247 None => (false, rule),
248 };
249 let prefix = match rule.find('/') {
250 Some(0) => "^/",
251 None => "/",
252 Some(i) if i == rule.len() - 1 => "/",
253 Some(_) => "",
254 };
255 let rule = rule.trim_matches('/');
256 let inner = glob_to_regex(rule);
257 let inner = &inner[2..inner.len() - 2];
258 let regex = pcre2::bytes::RegexBuilder::new()
259 .build(&format!("{prefix}{inner}(?=$|/)"))
260 .ok()?;
261 Some(ExcludePattern { regex, negate })
262}
263
264pub fn glob_to_regex(glob: &str) -> String {
267 let bytes = glob.as_bytes();
268 let mut first_byte = true;
269 let mut escaping = false;
270 let mut in_curlies = 0usize;
271 let mut regex = String::new();
272 let mut i = 0;
273 while i < bytes.len() {
274 let car = bytes[i] as char;
275 if first_byte && car != '.' {
276 regex.push_str("(?=[^\\.])");
277 }
278 first_byte = car == '/';
279 if first_byte
280 && i + 2 < bytes.len()
281 && bytes[i + 1] == b'*'
282 && bytes[i + 2] == b'*'
283 && (i + 3 >= bytes.len() || bytes[i + 3] == b'/')
284 {
285 let mut piece = String::from("[^/]++/");
286 if i + 3 >= bytes.len() {
287 piece.push('?');
288 }
289 let piece = format!("(?=[^\\.]){piece}");
290 regex.push_str(&format!("/(?:{piece})*"));
291 i += 2 + usize::from(i + 3 < bytes.len());
292 i += 1;
293 escaping = false;
294 continue;
295 }
296 match car {
297 '#' | '.' | '(' | ')' | '|' | '+' | '^' | '$' => {
298 regex.push('\\');
299 regex.push(car);
300 }
301 '*' => regex.push_str(if escaping { "\\*" } else { "[^/]*" }),
302 '?' => regex.push_str(if escaping { "\\?" } else { "[^/]" }),
303 '{' => {
304 if escaping {
305 regex.push_str("\\{");
306 } else {
307 regex.push('(');
308 in_curlies += 1;
309 }
310 }
311 '}' if in_curlies > 0 => {
312 if escaping {
313 regex.push('}');
314 } else {
315 regex.push(')');
316 in_curlies -= 1;
317 }
318 }
319 ',' if in_curlies > 0 => regex.push(if escaping { ',' } else { '|' }),
320 '\\' => {
321 if escaping {
322 regex.push_str("\\\\");
323 escaping = false;
324 } else {
325 escaping = true;
326 }
327 i += 1;
328 continue;
329 }
330 c => regex.push(c),
331 }
332 escaping = false;
333 i += 1;
334 }
335 format!("#^{regex}$#")
336}
337
338struct Entry {
340 rel: PathBuf,
341 kind: EntryKind,
342}
343
344enum EntryKind {
345 Link(PathBuf),
347 Dir,
349 File,
350}
351
352fn archivable_entries(source: &Path) -> Result<Vec<Entry>> {
355 let source_real = realpath(source).unwrap_or_else(|| source.to_path_buf());
356 let source_str = normalize_path(&source_real.to_string_lossy());
357 let patterns = git_exclude_patterns(source);
358 let mut out = Vec::new();
359 walk(source, source, &source_str, &patterns, &mut out)?;
360 Ok(out)
361}
362
363fn walk(
364 source: &Path,
365 dir: &Path,
366 source_str: &str,
367 patterns: &[ExcludePattern],
368 out: &mut Vec<Entry>,
369) -> Result<()> {
370 let mut names: Vec<std::ffi::OsString> = std::fs::read_dir(dir)
371 .map_err(Error::io(dir))?
372 .filter_map(|e| e.ok().map(|e| e.file_name()))
373 .collect();
374 names.sort();
375 for name in names {
376 let path = dir.join(&name);
377 let meta = std::fs::symlink_metadata(&path).map_err(Error::io(&path))?;
378 let is_link = meta.file_type().is_symlink();
379 let name_str = name.to_string_lossy();
381 if path.is_dir() && VCS_DIRS.contains(&name_str.as_ref()) {
382 continue;
383 }
384 let Some(real) = realpath(&path) else {
388 continue;
389 };
390 let real_str = normalize_path(&real.to_string_lossy());
391 if is_link && !real_str.starts_with(source_str) {
392 continue;
393 }
394 let relative = real_str.strip_prefix(source_str).unwrap_or(&real_str);
395 let mut exclude = false;
396 for p in patterns {
397 if p.regex.is_match(relative.as_bytes()).unwrap_or(false) {
398 exclude = !p.negate;
399 }
400 }
401 let rel = path.strip_prefix(source).unwrap_or(&path).to_path_buf();
402 if exclude {
403 if path.is_dir() && !is_link {
407 walk(source, &path, source_str, patterns, out)?;
408 }
409 continue;
410 }
411 if path.is_dir() {
412 let empty = std::fs::read_dir(&path)
415 .map(|mut rd| rd.next().is_none())
416 .unwrap_or(false);
417 if empty {
418 let kind = if is_link {
419 EntryKind::Link(std::fs::read_link(&path).map_err(Error::io(&path))?)
420 } else {
421 EntryKind::Dir
422 };
423 out.push(Entry { rel, kind });
424 } else if !is_link {
425 walk(source, &path, source_str, patterns, out)?;
426 }
427 } else if is_link {
428 out.push(Entry {
429 rel,
430 kind: EntryKind::Link(std::fs::read_link(&path).map_err(Error::io(&path))?),
431 });
432 } else {
433 out.push(Entry {
434 rel,
435 kind: EntryKind::File,
436 });
437 }
438 }
439 Ok(())
440}
441
442fn mirror(source: &Path, target: &Path) -> Result<()> {
444 std::fs::create_dir_all(target).map_err(Error::io(target))?;
445 for entry in archivable_entries(source)? {
446 let dest = target.join(&entry.rel);
447 let src = source.join(&entry.rel);
448 match entry.kind {
449 EntryKind::Link(raw_target) => {
450 if let Some(parent) = dest.parent() {
451 std::fs::create_dir_all(parent).map_err(Error::io(parent))?;
452 }
453 symlink(&raw_target, &dest)?;
454 }
455 EntryKind::Dir => {
456 std::fs::create_dir_all(&dest).map_err(Error::io(&dest))?;
457 }
458 EntryKind::File => copy_file(&src, &dest)?,
459 }
460 }
461 Ok(())
462}
463
464fn copy_file(src: &Path, dest: &Path) -> Result<()> {
467 if let Some(parent) = dest.parent() {
468 std::fs::create_dir_all(parent).map_err(Error::io(parent))?;
469 }
470 let meta = std::fs::metadata(src).map_err(Error::io(src))?;
471 {
472 let mut from = std::fs::File::open(src).map_err(Error::io(src))?;
473 let mut to = std::fs::File::create(dest).map_err(Error::io(dest))?;
474 std::io::copy(&mut from, &mut to).map_err(Error::io(dest))?;
475 }
476 #[cfg(unix)]
477 {
478 use std::os::unix::fs::PermissionsExt;
479 let current = std::fs::metadata(dest)
480 .map_err(Error::io(dest))?
481 .permissions()
482 .mode();
483 let mode = current | (meta.permissions().mode() & 0o111);
484 std::fs::set_permissions(dest, std::fs::Permissions::from_mode(mode))
485 .map_err(Error::io(dest))?;
486 }
487 if let Ok(modified) = meta.modified() {
488 let modified = modified
490 .duration_since(std::time::UNIX_EPOCH)
491 .map(|d| std::time::UNIX_EPOCH + std::time::Duration::from_secs(d.as_secs()))
492 .unwrap_or(modified);
493 let times = std::fs::FileTimes::new()
494 .set_modified(modified)
495 .set_accessed(modified);
496 let f = std::fs::File::options()
497 .write(true)
498 .open(dest)
499 .map_err(Error::io(dest))?;
500 f.set_times(times).map_err(Error::io(dest))?;
501 }
502 Ok(())
503}
504
505#[cfg(test)]
506mod tests {
507 use super::*;
508
509 #[test]
510 fn glob_to_regex_like_symfony() {
511 for (glob, regex) in [
513 ("*.md", "#^(?=[^\\.])[^/]*\\.md$#"),
514 ("docs", "#^(?=[^\\.])docs$#"),
515 (
516 "a/**/b",
517 "#^(?=[^\\.])a/(?:(?=[^\\.])[^/]++/)*(?=[^\\.])b$#",
518 ),
519 ("{a,b}.txt", "#^(?=[^\\.])(a|b)\\.txt$#"),
520 ("a/**", "#^(?=[^\\.])a/(?:(?=[^\\.])[^/]++/?)*$#"),
521 ("/docs", "#^(?=[^\\.])/(?=[^\\.])docs$#"),
522 (".hidden", "#^\\.hidden$#"),
523 ("a\\*b", "#^(?=[^\\.])a\\*b$#"),
524 ("x/*.php", "#^(?=[^\\.])x/(?=[^\\.])[^/]*\\.php$#"),
525 ] {
526 assert_eq!(glob_to_regex(glob), regex, "{glob}");
527 }
528 }
529
530 #[test]
531 fn exclude_patterns_like_composer() {
532 let p = generate_pattern("/docs").unwrap();
533 assert!(p.regex.is_match(b"/docs/guide.md").unwrap());
534 assert!(!p.regex.is_match(b"/src/docs").unwrap());
535 let p = generate_pattern("*.md").unwrap();
536 assert!(p.regex.is_match(b"/docs/guide.md").unwrap());
537 assert!(p.regex.is_match(b"/README.md").unwrap());
538 assert!(!p.regex.is_match(b"/README.md.txt").unwrap());
539 let p = generate_pattern("tests").unwrap();
540 assert!(p.regex.is_match(b"/src/tests/x.php").unwrap());
541 let p = generate_pattern("!README.md").unwrap();
542 assert!(p.negate);
543 }
544}