Fork of daniellemaywood.uk/gleam — Wasm codegen work
2

Configure Feed

Select the types of activity you want to include in your feed.

Stage git path dependencies via worktrees

Clone the repo once, check the commit out in a temp worktree, and
hard-link the dependency's subdirectory into build/packages.

+825 -198
+386 -111
compiler-cli/src/dependencies.rs
··· 520 520 let ManifestPackageSource::Git { repo, commit, path } = &package.source else { 521 521 continue; 522 522 }; 523 - let _ = download_git_package(&package.name, repo, commit, path.as_deref(), paths)?; 523 + 524 + let checkout = 525 + download_git_package(&package.name, repo, commit, path.as_deref(), paths)?; 526 + checkout.cleanup()?; 524 527 } 525 528 telemetry.packages_downloaded(start, num_to_download); 526 529 } ··· 561 564 fs::delete_directory(&path)?; 562 565 } 563 566 } 567 + } 568 + } 569 + 570 + remove_unused_git_clones(paths, manifest)?; 571 + Ok(()) 572 + } 573 + 574 + fn remove_unused_git_clones(paths: &ProjectPaths, manifest: &Manifest) -> Result<()> { 575 + let git_directory = paths.build_git_directory(); 576 + if !git_directory.is_dir() { 577 + return Ok(()); 578 + } 579 + 580 + let expected: HashSet<String> = manifest 581 + .packages 582 + .iter() 583 + .filter_map(|package| match &package.source { 584 + ManifestPackageSource::Git { 585 + repo, 586 + path: Some(_), 587 + .. 588 + } => Some(git_repo_dir_name(repo)), 589 + _ => None, 590 + }) 591 + .collect(); 592 + 593 + for entry in fs::read_dir(&git_directory)?.filter_map(Result::ok) { 594 + if !expected.contains(entry.file_name()) { 595 + tracing::debug!(path=%entry.path(), "removing_unused_git_clone"); 596 + fs::delete_directory(entry.path())?; 564 597 } 565 598 } 566 599 Ok(()) ··· 910 943 } 911 944 } 912 945 946 + /// Where a provided package came from. Git sources carry the on-disc repository 947 + /// root so path dependencies can be resolved relative to it. 948 + enum SourceContext<'a> { 949 + Local { 950 + path: Utf8PathBuf, 951 + }, 952 + Git { 953 + repo: EcoString, 954 + commit: EcoString, 955 + path: Option<Utf8PathBuf>, 956 + repo_root: &'a Utf8Path, 957 + }, 958 + } 959 + 960 + impl SourceContext<'_> { 961 + fn to_provided_source(&self) -> ProvidedPackageSource { 962 + match self { 963 + Self::Local { path } => ProvidedPackageSource::Local { path: path.clone() }, 964 + Self::Git { 965 + repo, commit, path, .. 966 + } => ProvidedPackageSource::Git { 967 + repo: repo.clone(), 968 + commit: commit.clone(), 969 + path: path.clone(), 970 + }, 971 + } 972 + } 973 + } 974 + 913 975 /// Provide a package from a local project 914 976 fn provide_local_package( 915 977 package_name: EcoString, ··· 925 987 fs::canonicalise(&parent_path.join(package_path))? 926 988 }; 927 989 928 - let package_source = ProvidedPackageSource::Local { 929 - path: package_path.clone(), 930 - }; 931 990 provide_package( 932 991 package_name, 933 - package_path, 934 - package_source, 992 + package_path.clone(), 993 + SourceContext::Local { path: package_path }, 935 994 project_paths, 936 995 provided, 937 996 parents, 938 997 ) 939 998 } 940 999 1000 + /// Resolve a path dependency of a git package to its canonical filesystem 1001 + /// location and its repository-relative path. 1002 + fn resolve_git_path_package( 1003 + package_name: &EcoString, 1004 + path: &Utf8Path, 1005 + repo: &EcoString, 1006 + parent_path: &Utf8Path, 1007 + repo_root: &Utf8Path, 1008 + ) -> Result<(Utf8PathBuf, Utf8PathBuf)> { 1009 + let location = parent_path.join(path); 1010 + if !location.is_dir() { 1011 + return Err(Error::GitDependencyPathNotFound { 1012 + package: package_name.to_string(), 1013 + path: path.to_string(), 1014 + repo: repo.to_string(), 1015 + }); 1016 + } 1017 + 1018 + // The path may name a symlink that points outside the repository 1019 + // checkout, so resolve it and take the repository-relative path from the 1020 + // canonical location. 1021 + let package_path = fs::canonicalise(&location)?; 1022 + let repo_path = package_path 1023 + .strip_prefix(repo_root) 1024 + .map_err(|_| Error::GitDependencyPathNotFound { 1025 + package: package_name.to_string(), 1026 + path: path.to_string(), 1027 + repo: repo.to_string(), 1028 + })? 1029 + .to_path_buf(); 1030 + 1031 + Ok((package_path, repo_path)) 1032 + } 1033 + 941 1034 fn execute_command(command: &mut Command) -> Result<std::process::Output> { 942 1035 let result = command.output(); 943 1036 match result { ··· 966 1059 } 967 1060 } 968 1061 969 - fn git_repo_dir_name(repo: &str, ref_: &str) -> String { 970 - let sanitized = repo 1062 + fn git_repo_dir_name(repo: &str) -> String { 1063 + let name = repo 1064 + .trim_end_matches('/') 971 1065 .trim_end_matches(".git") 972 - .replace("://", "-") 973 - .replace(['/', ':', '@'], "-"); 974 - let sanitized = sanitized.trim_matches('-'); 975 - let hash = xxhash_rust::xxh3::xxh3_64(format!("{repo}\0{ref_}").as_bytes()); 976 - format!("{sanitized}-{hash:016x}") 1066 + .rsplit(['/', ':']) 1067 + .next() 1068 + .filter(|name| !name.is_empty()) 1069 + .unwrap_or("repo"); 1070 + let hash = xxhash_rust::xxh3::xxh3_64(repo.as_bytes()); 1071 + format!("{name}-{hash:016x}") 977 1072 } 978 1073 979 - /// Reject paths that escape the repository root via `..` components or absolute 980 - /// paths so that typos like `path = "../oops"` fail early with a clear error 981 - /// instead of silently linking the wrong directory. 982 - fn validate_git_dependency_path(package_name: &str, path: &Utf8Path, repo: &str) -> Result<()> { 983 - if path.is_absolute() || path.as_str().starts_with('/') { 984 - return Err(Error::GitDependencyPathNotFound { 985 - package: package_name.into(), 986 - path: path.to_string(), 987 - repo: repo.into(), 988 - }); 989 - } 990 - for component in path.components() { 991 - if matches!(component, camino::Utf8Component::ParentDir) { 992 - return Err(Error::GitDependencyPathNotFound { 993 - package: package_name.into(), 994 - path: path.to_string(), 995 - repo: repo.into(), 996 - }); 1074 + fn git_staging_path(project_paths: &ProjectPaths, repo: &str) -> Utf8PathBuf { 1075 + project_paths.build_git_repo(&format!("{}-staging", git_repo_dir_name(repo))) 1076 + } 1077 + 1078 + enum GitCheckout { 1079 + InPlace { 1080 + commit: EcoString, 1081 + }, 1082 + Staged { 1083 + commit: EcoString, 1084 + staging_path: Utf8PathBuf, 1085 + }, 1086 + } 1087 + 1088 + impl GitCheckout { 1089 + fn cleanup(self) -> Result<()> { 1090 + // The clones dangling worktree registration is left for the next 1091 + // download to prune before it re-adds the staging worktree. 1092 + if let Self::Staged { staging_path, .. } = self { 1093 + fs::delete_directory(&staging_path)?; 997 1094 } 1095 + Ok(()) 998 1096 } 999 - Ok(()) 1000 1097 } 1001 1098 1002 - /// Downloads a git package from a remote repository. The commands that are run 1003 - /// looks like this: 1099 + /// Downloads a git package from a remote repository. 1100 + /// 1101 + /// For a package at the root of its repository the commands that are run look 1102 + /// like this, cloning directly into `build/packages/<name>`: 1004 1103 /// 1005 1104 /// ```sh 1006 1105 /// git init ··· 1011 1110 /// git rev-parse HEAD 1012 1111 /// ``` 1013 1112 /// 1113 + /// For a package in a subdirectory of its repository the repository is 1114 + /// instead cloned into `build/git/<repo-name>-<hash>` without ever populating 1115 + /// its worktree, and the package is checked out into a transient staging 1116 + /// worktree which the subdirectory is hard-linked out of: 1117 + /// 1118 + /// ```sh 1119 + /// git init 1120 + /// git remote remove origin 1121 + /// git remote add origin <repo> 1122 + /// git fetch origin 1123 + /// git rev-parse --verify --quiet <ref>^{commit} 1124 + /// git worktree prune 1125 + /// git worktree add --force --detach <clone>-staging <commit> 1126 + /// ``` 1127 + /// 1128 + /// The staging worktree exists only for the duration of one package download. 1129 + /// It is deleted (and the clone's worktree registration pruned) by 1130 + /// `GitCheckout::cleanup` once the package has been hard-linked and read. 1131 + /// 1014 1132 /// This is somewhat inefficient as we have to fetch the entire git history before 1015 1133 /// switching to the exact commit we want. There a few alternatives to this: 1016 1134 /// ··· 1035 1153 ref_: &str, 1036 1154 path: Option<&Utf8Path>, 1037 1155 project_paths: &ProjectPaths, 1038 - ) -> Result<EcoString> { 1039 - // When a subdirectory path is specified, clone to a staging area. 1040 - // Otherwise clone directly to the package directory. 1041 - let (clone_path, subdir) = match path { 1042 - None => (project_paths.build_packages_package(package_name), None), 1156 + ) -> Result<GitCheckout> { 1157 + match path { 1158 + None => download_git_package_in_place(package_name, repo, ref_, project_paths), 1043 1159 Some(subdir) => { 1044 - validate_git_dependency_path(package_name, subdir, repo)?; 1160 + download_git_package_to_staged_path(package_name, repo, ref_, project_paths, subdir) 1161 + } 1162 + } 1163 + } 1164 + 1165 + fn download_git_package_in_place( 1166 + package_name: &str, 1167 + repo: &str, 1168 + ref_: &str, 1169 + project_paths: &ProjectPaths, 1170 + ) -> Result<GitCheckout, Error> { 1171 + let clone_path = project_paths.build_packages_package(package_name); 1172 + prepare_git_clone(&clone_path, repo)?; 1173 + let _ = execute_command( 1174 + Command::new("git") 1175 + .arg("checkout") 1176 + .arg(ref_) 1177 + .current_dir(&clone_path), 1178 + )?; 1179 + let output = execute_command( 1180 + Command::new("git") 1181 + .arg("rev-parse") 1182 + .arg("HEAD") 1183 + .current_dir(&clone_path), 1184 + )?; 1185 + let commit = String::from_utf8(output.stdout) 1186 + .expect("Output should be UTF-8") 1187 + .trim() 1188 + .into(); 1189 + 1190 + Ok(GitCheckout::InPlace { commit }) 1191 + } 1192 + 1193 + fn download_git_package_to_staged_path( 1194 + package_name: &str, 1195 + repo: &str, 1196 + ref_: &str, 1197 + project_paths: &ProjectPaths, 1198 + subdir: &Utf8Path, 1199 + ) -> Result<GitCheckout, Error> { 1200 + let clone_path = project_paths.build_git_repo(&git_repo_dir_name(repo)); 1201 + prepare_git_clone(&clone_path, repo)?; 1202 + 1203 + let commit = resolve_git_ref(&clone_path, repo, ref_)?; 1204 + 1205 + // Delete any staging worktree left behind by a previous crash, and prune 1206 + // its registration from the clone so `git worktree add` can reuse the 1207 + // path. 1208 + let staging_path = git_staging_path(project_paths, repo); 1209 + fs::delete_directory(&staging_path)?; 1210 + let _ = Command::new("git") 1211 + .arg("worktree") 1212 + .arg("prune") 1213 + .current_dir(&clone_path) 1214 + .output(); 1045 1215 1046 - let staging_name = git_repo_dir_name(repo, ref_); 1047 - let staging_path = project_paths.build_git_repo(&staging_name); 1048 - (staging_path, Some(subdir)) 1049 - } 1216 + let _ = execute_command( 1217 + Command::new("git") 1218 + .arg("worktree") 1219 + .arg("add") 1220 + .arg("--force") 1221 + .arg("--detach") 1222 + .arg(&staging_path) 1223 + .arg(commit.as_str()) 1224 + .current_dir(&clone_path), 1225 + )?; 1226 + 1227 + let Some(subdir_source) = resolve_git_subdir(&staging_path, subdir) else { 1228 + return Err(Error::GitDependencyPathNotFound { 1229 + package: package_name.into(), 1230 + path: subdir.to_string(), 1231 + repo: repo.into(), 1232 + }); 1050 1233 }; 1051 1234 1235 + let package_path = project_paths.build_packages_package(package_name); 1236 + fs::delete_directory(&package_path)?; 1237 + fs::mkdir(&package_path)?; 1238 + fs::hardlink_dir(&subdir_source, &package_path)?; 1239 + 1240 + Ok(GitCheckout::Staged { 1241 + commit, 1242 + staging_path, 1243 + }) 1244 + } 1245 + 1246 + /// Initialise (or reuse) a git clone at the given path and fetch from the 1247 + /// remote repository. 1248 + fn prepare_git_clone(clone_path: &Utf8Path, repo: &str) -> Result<()> { 1052 1249 // If the clone path exists but is not inside a git work tree, we need to 1053 1250 // remove the directory because running `git init` in a non-empty directory 1054 1251 // followed by `git checkout ...` is an error. See 1055 1252 // https://github.com/gleam-lang/gleam/issues/4488 for details. 1056 - if !fs::is_git_work_tree_root(&clone_path) { 1057 - fs::delete_directory(&clone_path)?; 1253 + if !fs::is_git_work_tree_root(clone_path) { 1254 + fs::delete_directory(clone_path)?; 1058 1255 } 1059 1256 1060 - fs::mkdir(&clone_path)?; 1257 + fs::mkdir(clone_path)?; 1061 1258 1062 - let _ = execute_command(Command::new("git").arg("init").current_dir(&clone_path))?; 1259 + let _ = execute_command(Command::new("git").arg("init").current_dir(clone_path))?; 1063 1260 1064 1261 // If this directory already exists, but the remote URL has been edited in 1065 1262 // `gleam.toml` without a `gleam clean`, `git remote add` will fail, causing ··· 1071 1268 .arg("remote") 1072 1269 .arg("remove") 1073 1270 .arg("origin") 1074 - .current_dir(&clone_path) 1271 + .current_dir(clone_path) 1075 1272 .output(); 1076 1273 1077 1274 let _ = execute_command( ··· 1080 1277 .arg("add") 1081 1278 .arg("origin") 1082 1279 .arg(repo) 1083 - .current_dir(&clone_path), 1280 + .current_dir(clone_path), 1084 1281 )?; 1085 1282 1086 1283 let _ = execute_command( 1087 1284 Command::new("git") 1088 1285 .arg("fetch") 1089 1286 .arg("origin") 1090 - .current_dir(&clone_path), 1287 + .current_dir(clone_path), 1091 1288 )?; 1092 1289 1093 - let _ = execute_command( 1094 - Command::new("git") 1095 - .arg("checkout") 1096 - .arg(ref_) 1097 - .current_dir(&clone_path), 1098 - )?; 1290 + Ok(()) 1291 + } 1099 1292 1100 - let output = execute_command( 1101 - Command::new("git") 1102 - .arg("rev-parse") 1103 - .arg("HEAD") 1104 - .current_dir(&clone_path), 1105 - )?; 1293 + /// Resolve a ref (a branch name, tag, or full or partial commit hash) to a 1294 + /// full commit hash using the objects fetched into the clone, without 1295 + /// checking anything out. Branch names only exist as remote-tracking refs in 1296 + /// the never-checked-out clone, so when the ref does not resolve directly we 1297 + /// fall back to `origin/<ref>`. 1298 + fn resolve_git_ref(clone_path: &Utf8Path, repo: &str, ref_: &str) -> Result<EcoString> { 1299 + let revisions = [ 1300 + format!("{ref_}^{{commit}}"), 1301 + format!("origin/{ref_}^{{commit}}"), 1302 + ]; 1106 1303 1107 - let commit = String::from_utf8(output.stdout) 1108 - .expect("Output should be UTF-8") 1109 - .trim() 1110 - .into(); 1304 + for revision in revisions { 1305 + let result = Command::new("git") 1306 + .arg("rev-parse") 1307 + .arg("--verify") 1308 + .arg("--quiet") 1309 + .arg(&revision) 1310 + .current_dir(clone_path) 1311 + .output(); 1111 1312 1112 - // If a subdirectory was specified, hard-link it into the package directory 1113 - if let Some(subdir) = subdir { 1114 - let subdir_source = clone_path.join(subdir); 1115 - if !subdir_source.is_dir() { 1116 - return Err(Error::GitDependencyPathNotFound { 1117 - package: package_name.into(), 1118 - path: subdir.to_string(), 1119 - repo: repo.into(), 1120 - }); 1313 + match result { 1314 + // A failed rev-parse is expected when the ref form doesn't match 1315 + // this pattern, so try the next one. 1316 + Ok(output) if !output.status.success() => (), 1317 + Ok(output) => { 1318 + let commit = String::from_utf8(output.stdout) 1319 + .expect("Output should be UTF-8") 1320 + .trim() 1321 + .into(); 1322 + return Ok(commit); 1323 + } 1324 + Err(error) => { 1325 + return Err(match error.kind() { 1326 + ErrorKind::NotFound => Error::ShellProgramNotFound { 1327 + program: "git".into(), 1328 + os: fs::get_os(), 1329 + }, 1330 + other => Error::ShellCommand { 1331 + program: "git".into(), 1332 + reason: ShellCommandFailureReason::IoError(other), 1333 + }, 1334 + }); 1335 + } 1121 1336 } 1337 + } 1122 1338 1123 - let package_path = project_paths.build_packages_package(package_name); 1124 - fs::delete_directory(&package_path)?; 1125 - fs::mkdir(&package_path)?; 1126 - fs::hardlink_dir(&subdir_source, &package_path)?; 1339 + Err(Error::ShellCommand { 1340 + program: "git".into(), 1341 + reason: ShellCommandFailureReason::ShellCommandError(format!( 1342 + "Unable to resolve git ref `{ref_}` for repository `{repo}`\n" 1343 + )), 1344 + }) 1345 + } 1346 + 1347 + /// Resolves a subdirectory within a cloned git repository, ensuring that the 1348 + /// resolved directory (after following any symlinks) is still inside the 1349 + /// repository checkout. Returns `None` if the directory does not exist or 1350 + /// escapes the checkout. 1351 + fn resolve_git_subdir(clone_path: &Utf8Path, subdir: &Utf8Path) -> Option<Utf8PathBuf> { 1352 + let subdir_source = clone_path.join(subdir); 1353 + if !subdir_source.is_dir() { 1354 + return None; 1127 1355 } 1128 1356 1129 - Ok(commit) 1357 + let canonical_clone = fs::canonicalise(clone_path).ok()?; 1358 + let canonical_subdir = fs::canonicalise(&subdir_source).ok()?; 1359 + if !canonical_subdir.starts_with(&canonical_clone) { 1360 + return None; 1361 + } 1362 + 1363 + Some(canonical_subdir) 1130 1364 } 1131 1365 1132 1366 /// Provide a package from a git repository ··· 1140 1374 provided: &mut HashMap<EcoString, ProvidedPackage>, 1141 1375 parents: &mut Vec<EcoString>, 1142 1376 ) -> Result<hexpm::version::Range> { 1143 - let commit = download_git_package(&package_name, repo, ref_, path.as_deref(), project_paths)?; 1144 - 1145 - let package_source = ProvidedPackageSource::Git { 1146 - repo: repo.into(), 1147 - commit, 1148 - path: path.clone(), 1377 + let checkout = download_git_package(&package_name, repo, ref_, path.as_deref(), project_paths)?; 1378 + let (commit, staging_path) = match &checkout { 1379 + GitCheckout::InPlace { commit } => (commit, None), 1380 + GitCheckout::Staged { 1381 + commit, 1382 + staging_path, 1383 + } => (commit, Some(staging_path)), 1149 1384 }; 1150 1385 1151 - // When a subdirectory path is used, resolve the package from the staging 1152 - // clone so that transitive path dependencies (e.g. `../sibling`) resolve 1153 - // against the original repository structure rather than build/packages/. 1154 - let package_path = match path { 1155 - Some(ref subdir) => { 1156 - let staging_name = git_repo_dir_name(repo, ref_); 1157 - let staging_path = project_paths.build_git_repo(&staging_name); 1158 - fs::canonicalise(&staging_path.join(subdir))? 1386 + // Use the package's location in the staging worktree, where its gleam.toml 1387 + // lives, not build/packages/. A `../sibling` path dep is resolved next to 1388 + // that gleam.toml, and the sibling is only present in the worktree. 1389 + let (package_path, repo_root) = match (&path, staging_path) { 1390 + (Some(subdir), Some(staging_path)) => { 1391 + let repo_root = fs::canonicalise(staging_path)?; 1392 + (fs::canonicalise(&staging_path.join(subdir))?, repo_root) 1393 + } 1394 + _ => { 1395 + let package_path = 1396 + fs::canonicalise(&project_paths.build_packages_package(&package_name))?; 1397 + (package_path.clone(), package_path) 1159 1398 } 1160 - None => fs::canonicalise(&project_paths.build_packages_package(&package_name))?, 1161 1399 }; 1162 1400 1163 - provide_package( 1401 + let version = provide_package( 1164 1402 package_name, 1165 1403 package_path, 1166 - package_source, 1404 + SourceContext::Git { 1405 + repo: repo.into(), 1406 + commit: commit.clone(), 1407 + path: path.clone(), 1408 + repo_root: &repo_root, 1409 + }, 1167 1410 project_paths, 1168 1411 provided, 1169 1412 parents, 1170 - ) 1413 + )?; 1414 + 1415 + checkout.cleanup()?; 1416 + Ok(version) 1171 1417 } 1172 1418 1173 1419 /// Adds a gleam project located at a specific path to the list of "provided packages" 1174 1420 fn provide_package( 1175 1421 package_name: EcoString, 1176 1422 package_path: Utf8PathBuf, 1177 - package_source: ProvidedPackageSource, 1423 + source: SourceContext<'_>, 1178 1424 project_paths: &ProjectPaths, 1179 1425 provided: &mut HashMap<EcoString, ProvidedPackage>, 1180 1426 parents: &mut Vec<EcoString>, 1181 1427 ) -> Result<hexpm::version::Range> { 1428 + let package_source = source.to_provided_source(); 1429 + 1182 1430 // Return early if a package cycle is detected 1183 1431 if parents.contains(&package_name) { 1184 1432 let mut last_cycle = parents ··· 1225 1473 for (name, requirement) in config.dependencies.into_iter() { 1226 1474 let version = match requirement { 1227 1475 Requirement::Hex { version } => version, 1228 - Requirement::Path { path } => { 1229 - // Recursively walk local packages 1230 - provide_local_package( 1231 - name.clone(), 1232 - &path, 1233 - &package_path, 1234 - project_paths, 1235 - provided, 1236 - parents, 1237 - )? 1238 - } 1476 + Requirement::Path { path } => match &source { 1477 + // A path dependency of a git package points to another 1478 + // package within the same repository, so lock it as a git 1479 + // source to keep the manifest portable. 1480 + SourceContext::Git { 1481 + repo, 1482 + commit, 1483 + repo_root, 1484 + .. 1485 + } => { 1486 + let (child_path, child_repo_path) = 1487 + resolve_git_path_package(&name, &path, repo, &package_path, repo_root)?; 1488 + provide_package( 1489 + name.clone(), 1490 + child_path, 1491 + SourceContext::Git { 1492 + repo: repo.clone(), 1493 + commit: commit.clone(), 1494 + path: Some(child_repo_path), 1495 + repo_root, 1496 + }, 1497 + project_paths, 1498 + provided, 1499 + parents, 1500 + )? 1501 + } 1502 + SourceContext::Local { .. } => { 1503 + // Recursively walk local packages 1504 + provide_local_package( 1505 + name.clone(), 1506 + &path, 1507 + &package_path, 1508 + project_paths, 1509 + provided, 1510 + parents, 1511 + )? 1512 + } 1513 + }, 1239 1514 Requirement::Git { git, ref_, path } => provide_git_package( 1240 1515 name.clone(), 1241 1516 &git,
+264 -49
compiler-cli/src/dependencies/tests.rs
··· 554 554 let result = provide_package( 555 555 "hello_world".into(), 556 556 Utf8PathBuf::from("./test/other"), 557 - ProvidedPackageSource::Local { 557 + SourceContext::Local { 558 558 path: Utf8Path::new("./test/other").to_path_buf(), 559 559 }, 560 560 &project_paths, ··· 815 815 } 816 816 817 817 #[test] 818 - fn validate_git_dependency_path_accepts_subdir() { 819 - assert!( 820 - validate_git_dependency_path( 821 - "package", 822 - Utf8Path::new("subdir"), 823 - "https://github.com/gleam-lang/gleam.git" 824 - ) 825 - .is_ok() 818 + fn provided_git_path_package_resolves_repo_relative_path() { 819 + let repo_root = fs::canonicalise(Utf8Path::new("./test")).unwrap(); 820 + let result = resolve_git_path_package( 821 + &"hello_world".into(), 822 + Utf8Path::new("../hello_world"), 823 + &"https://github.com/gleam-lang/wibble.git".into(), 824 + &repo_root.join("hello_world"), 825 + &repo_root, 826 + ); 827 + assert_eq!( 828 + result, 829 + Ok((repo_root.join("hello_world"), "hello_world".into())) 826 830 ); 827 831 } 828 832 829 833 #[test] 830 - fn validate_git_dependency_path_accepts_nested_subdir() { 831 - assert!( 832 - validate_git_dependency_path( 833 - "package", 834 - Utf8Path::new("packages/subdir"), 835 - "https://github.com/gleam-lang/gleam.git" 836 - ) 837 - .is_ok() 834 + fn provided_git_path_package_child_uses_canonical_parent_location() { 835 + let tmp = tempfile::tempdir().unwrap(); 836 + let base = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap(); 837 + let repo = base.join("repo"); 838 + fs::mkdir(&repo.join("packages").join("package_a")).unwrap(); 839 + fs::mkdir(&repo.join("packages").join("package_b")).unwrap(); 840 + fs::write( 841 + &repo.join("packages").join("package_a").join("gleam.toml"), 842 + r#" 843 + name = "package_a" 844 + version = "0.1.0" 845 + 846 + [dependencies] 847 + package_b = { path = "../package_b" } 848 + "#, 849 + ) 850 + .unwrap(); 851 + fs::write( 852 + &repo.join("packages").join("package_b").join("gleam.toml"), 853 + r#" 854 + name = "package_b" 855 + version = "0.2.0" 856 + "#, 857 + ) 858 + .unwrap(); 859 + 860 + let mut provided = HashMap::new(); 861 + let project_paths = crate::project_paths_at_current_directory_without_toml(); 862 + let repo_root = fs::canonicalise(&repo).unwrap(); 863 + let result = provide_package( 864 + "package_a".into(), 865 + fs::canonicalise(&repo.join("packages").join("package_a")).unwrap(), 866 + SourceContext::Git { 867 + repo: "https://github.com/gleam-lang/wibble.git".into(), 868 + commit: "95cd2c2f45907e5571e9b5fcdfb27ff35cdcdd29".into(), 869 + path: Some("packages/package_a".into()), 870 + repo_root: &repo_root, 871 + }, 872 + &project_paths, 873 + &mut provided, 874 + &mut vec!["root".into()], 875 + ); 876 + assert_eq!( 877 + result, 878 + Ok(hexpm::version::Range::new("== 0.1.0".into()).unwrap()) 879 + ); 880 + let package = provided.get("package_b").unwrap(); 881 + assert_eq!( 882 + package.source, 883 + ProvidedPackageSource::Git { 884 + repo: "https://github.com/gleam-lang/wibble.git".into(), 885 + commit: "95cd2c2f45907e5571e9b5fcdfb27ff35cdcdd29".into(), 886 + path: Some("packages/package_b".into()), 887 + } 838 888 ); 839 889 } 840 890 841 891 #[test] 842 - fn validate_git_dependency_path_rejects_parent_traversal() { 843 - let result = validate_git_dependency_path( 844 - "package", 845 - Utf8Path::new("../escape"), 846 - "https://github.com/gleam-lang/gleam.git", 892 + fn provided_git_path_package_rejects_missing_directory() { 893 + let tmp = tempfile::tempdir().unwrap(); 894 + let base = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap(); 895 + let repo = base.join("repo"); 896 + fs::mkdir(&repo.join("parent")).unwrap(); 897 + 898 + let repo_root = fs::canonicalise(&repo).unwrap(); 899 + let result = resolve_git_path_package( 900 + &"missing".into(), 901 + Utf8Path::new("../missing"), 902 + &"https://github.com/gleam-lang/wibble.git".into(), 903 + &repo_root.join("parent"), 904 + &repo_root, 847 905 ); 848 - assert!(matches!( 906 + assert_eq!( 849 907 result, 850 - Err(Error::GitDependencyPathNotFound { .. }) 851 - )); 908 + Err(Error::GitDependencyPathNotFound { 909 + package: "missing".into(), 910 + path: "../missing".into(), 911 + repo: "https://github.com/gleam-lang/wibble.git".into(), 912 + }) 913 + ); 914 + } 915 + 916 + #[test] 917 + fn resolve_git_subdir_accepts_directory_inside_repo() { 918 + let tmp = tempfile::tempdir().unwrap(); 919 + let base = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap(); 920 + let clone = base.join("clone"); 921 + fs::mkdir(&clone.join("sub")).unwrap(); 922 + 923 + let expected = fs::canonicalise(&clone).unwrap().join("sub"); 924 + assert_eq!( 925 + resolve_git_subdir(&clone, Utf8Path::new("sub")), 926 + Some(expected) 927 + ); 928 + } 929 + 930 + #[test] 931 + fn resolve_git_subdir_rejects_missing_directory() { 932 + let tmp = tempfile::tempdir().unwrap(); 933 + let base = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap(); 934 + let clone = base.join("clone"); 935 + fs::mkdir(&clone).unwrap(); 936 + 937 + assert_eq!(resolve_git_subdir(&clone, Utf8Path::new("missing")), None); 852 938 } 853 939 940 + #[cfg(unix)] 854 941 #[test] 855 - fn validate_git_dependency_path_rejects_nested_parent_traversal() { 856 - let result = validate_git_dependency_path( 857 - "package", 858 - Utf8Path::new("packages/../../escape"), 859 - "https://github.com/gleam-lang/gleam.git", 942 + fn resolve_git_subdir_rejects_symlink_escaping_repo() { 943 + let tmp = tempfile::tempdir().unwrap(); 944 + let base = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap(); 945 + let clone = base.join("clone"); 946 + let outside = base.join("outside"); 947 + fs::mkdir(&clone).unwrap(); 948 + fs::mkdir(&outside).unwrap(); 949 + std::os::unix::fs::symlink(outside.as_std_path(), clone.join("escape").as_std_path()).unwrap(); 950 + 951 + assert_eq!(resolve_git_subdir(&clone, Utf8Path::new("escape")), None); 952 + } 953 + 954 + #[test] 955 + fn provided_git_path_package_rejects_escaping_path() { 956 + let tmp = tempfile::tempdir().unwrap(); 957 + let base = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap(); 958 + let repo = base.join("repo"); 959 + let outside = base.join("outside"); 960 + fs::mkdir(&repo.join("parent")).unwrap(); 961 + fs::mkdir(&outside).unwrap(); 962 + 963 + let repo_root = fs::canonicalise(&repo).unwrap(); 964 + let result = resolve_git_path_package( 965 + &"package_a".into(), 966 + Utf8Path::new("../../outside"), 967 + &"https://github.com/gleam-lang/wibble.git".into(), 968 + &repo_root.join("parent"), 969 + &repo_root, 860 970 ); 861 - assert!(matches!( 971 + assert_eq!( 862 972 result, 863 - Err(Error::GitDependencyPathNotFound { .. }) 864 - )); 973 + Err(Error::GitDependencyPathNotFound { 974 + package: "package_a".into(), 975 + path: "../../outside".into(), 976 + repo: "https://github.com/gleam-lang/wibble.git".into(), 977 + }) 978 + ); 865 979 } 866 980 981 + #[cfg(unix)] 867 982 #[test] 868 - fn validate_git_dependency_path_rejects_absolute_path() { 869 - let result = validate_git_dependency_path( 870 - "package", 871 - Utf8Path::new("/etc/passwd"), 872 - "https://github.com/gleam-lang/gleam.git", 983 + fn provided_git_path_package_rejects_symlink_escaping_repo() { 984 + let tmp = tempfile::tempdir().unwrap(); 985 + let base = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap(); 986 + let repo = base.join("repo"); 987 + let outside = base.join("outside"); 988 + fs::mkdir(&repo.join("parent")).unwrap(); 989 + fs::mkdir(&outside).unwrap(); 990 + std::os::unix::fs::symlink(outside.as_std_path(), repo.join("escape").as_std_path()).unwrap(); 991 + 992 + let repo_root = fs::canonicalise(&repo).unwrap(); 993 + let result = resolve_git_path_package( 994 + &"escape".into(), 995 + Utf8Path::new("../escape"), 996 + &"https://github.com/gleam-lang/wibble.git".into(), 997 + &repo_root.join("parent"), 998 + &repo_root, 873 999 ); 874 - assert!(matches!( 1000 + assert_eq!( 875 1001 result, 876 - Err(Error::GitDependencyPathNotFound { .. }) 877 - )); 1002 + Err(Error::GitDependencyPathNotFound { 1003 + package: "escape".into(), 1004 + path: "../escape".into(), 1005 + repo: "https://github.com/gleam-lang/wibble.git".into(), 1006 + }) 1007 + ); 878 1008 } 879 1009 880 1010 #[test] 881 - fn git_repo_dir_name_produces_expected_name() { 1011 + fn git_repo_dir_name_uses_repo_basename_and_hash_of_full_url() { 1012 + assert_eq!( 1013 + git_repo_dir_name("https://github.com/gleam-lang/gleam"), 1014 + "gleam-4d40009bf5eb0110" 1015 + ); 882 1016 assert_eq!( 883 - git_repo_dir_name("https://github.com/gleam-lang/gleam.git", "main"), 884 - "https-github.com-gleam-lang-gleam-edfec3bb6bbeb6c1" 1017 + git_repo_dir_name("https://github.com/gleam-lang/gleam.git"), 1018 + "gleam-ea2aeaa3761e8bc6" 1019 + ); 1020 + assert_eq!( 1021 + git_repo_dir_name("git@github.com:gleam-lang/gleam.git"), 1022 + "gleam-102c9e1e5cf87965" 885 1023 ); 886 1024 } 887 1025 888 1026 #[test] 889 - fn git_repo_dir_name_different_refs_produce_different_names() { 1027 + fn git_staging_path_is_clone_path_with_staging_suffix() { 1028 + let paths = ProjectPaths::new("/app".into()); 1029 + let repo = "https://github.com/gleam-lang/gleam.git"; 890 1030 assert_eq!( 891 - git_repo_dir_name("https://github.com/gleam-lang/gleam.git", "main"), 892 - "https-github.com-gleam-lang-gleam-edfec3bb6bbeb6c1" 1031 + paths.build_git_repo(&git_repo_dir_name(repo)), 1032 + Utf8PathBuf::from("/app/build/git/gleam-ea2aeaa3761e8bc6") 893 1033 ); 894 1034 assert_eq!( 895 - git_repo_dir_name("https://github.com/gleam-lang/gleam.git", "v1.0.0"), 896 - "https-github.com-gleam-lang-gleam-90203c669cc91eee" 1035 + git_staging_path(&paths, repo), 1036 + Utf8PathBuf::from("/app/build/git/gleam-ea2aeaa3761e8bc6-staging") 897 1037 ); 1038 + } 1039 + 1040 + #[test] 1041 + fn git_checkout_cleanup_deletes_staging_directory() { 1042 + let tmp = tempfile::tempdir().unwrap(); 1043 + let base = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap(); 1044 + let staging_path = base.join("clone-staging"); 1045 + fs::mkdir(&staging_path).unwrap(); 1046 + fs::write(&staging_path.join("gleam.toml"), "name = \"wibble\"").unwrap(); 1047 + 1048 + let checkout = GitCheckout::Staged { 1049 + commit: "95cd2c2f45907e5571e9b5fcdfb27ff35cdcdd29".into(), 1050 + staging_path: staging_path.clone(), 1051 + }; 1052 + assert_eq!(checkout.cleanup(), Ok(())); 1053 + 1054 + assert!(!staging_path.exists()); 1055 + } 1056 + 1057 + #[test] 1058 + fn git_checkout_cleanup_without_staging_is_noop() { 1059 + let checkout = GitCheckout::InPlace { 1060 + commit: "95cd2c2f45907e5571e9b5fcdfb27ff35cdcdd29".into(), 1061 + }; 1062 + assert_eq!(checkout.cleanup(), Ok(())); 1063 + } 1064 + 1065 + #[test] 1066 + fn remove_unused_git_clones_sweeps_unexpected_directories() { 1067 + let tmp = tempfile::tempdir().unwrap(); 1068 + let root = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap(); 1069 + let paths = ProjectPaths::new(root); 1070 + let repo = "https://github.com/gleam-lang/gleam.git"; 1071 + 1072 + let expected_clone = paths.build_git_repo(&git_repo_dir_name(repo)); 1073 + let stale_clone = paths.build_git_repo("other-0011223344556677"); 1074 + let stale_staging = paths.build_git_repo(&format!("{}-staging", git_repo_dir_name(repo))); 1075 + fs::mkdir(&expected_clone).unwrap(); 1076 + fs::mkdir(&stale_clone).unwrap(); 1077 + fs::mkdir(&stale_staging).unwrap(); 1078 + 1079 + let manifest = Manifest { 1080 + requirements: HashMap::new(), 1081 + packages: vec![ManifestPackage { 1082 + name: "wibble".into(), 1083 + version: Version::new(1, 0, 0), 1084 + build_tools: ["gleam".into()].into(), 1085 + otp_app: None, 1086 + requirements: vec![], 1087 + source: ManifestPackageSource::Git { 1088 + repo: repo.into(), 1089 + commit: "95cd2c2f45907e5571e9b5fcdfb27ff35cdcdd29".into(), 1090 + path: Some("wibble".into()), 1091 + }, 1092 + }], 1093 + }; 1094 + 1095 + assert_eq!(remove_unused_git_clones(&paths, &manifest), Ok(())); 1096 + 1097 + assert!(expected_clone.is_dir()); 1098 + assert!(!stale_clone.exists()); 1099 + assert!(!stale_staging.exists()); 1100 + } 1101 + 1102 + #[test] 1103 + fn remove_unused_git_clones_missing_directory_is_noop() { 1104 + let tmp = tempfile::tempdir().unwrap(); 1105 + let root = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap(); 1106 + let paths = ProjectPaths::new(root); 1107 + let manifest = Manifest { 1108 + requirements: HashMap::new(), 1109 + packages: vec![], 1110 + }; 1111 + 1112 + assert_eq!(remove_unused_git_clones(&paths, &manifest), Ok(())); 898 1113 } 899 1114 900 1115 #[test]
+1
compiler-cli/src/fs.rs
··· 733 733 .map(|_| ()) 734 734 } 735 735 736 + /// Recursively hardlinks all files from one directory into another. 736 737 pub fn hardlink_dir( 737 738 from: impl AsRef<Utf8Path> + Debug, 738 739 to: impl AsRef<Utf8Path> + Debug,
+1 -20
compiler-cli/src/fs/tests.rs
··· 217 217 } 218 218 219 219 #[test] 220 - fn hardlink_dir_skips_git_directory() { 221 - let tmp = tempfile::tempdir().unwrap(); 222 - let base = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap(); 223 - let src = base.join("src"); 224 - let dest = base.join("dest"); 225 - 226 - super::mkdir(&src).unwrap(); 227 - super::mkdir(&src.join(".git")).unwrap(); 228 - super::write(&src.join("a.txt"), "content").unwrap(); 229 - super::write(&src.join(".git").join("config"), "gitdata").unwrap(); 230 - 231 - super::mkdir(&dest).unwrap(); 232 - super::hardlink_dir(&src, &dest).unwrap(); 233 - 234 - assert!(dest.join("a.txt").exists()); 235 - assert!(!dest.join(".git").exists()); 236 - } 237 - 238 - #[test] 239 - fn hardlink_dir_empty_source() { 220 + fn hardlink_dir_empty_source_creates_empty_dest() { 240 221 let tmp = tempfile::tempdir().unwrap(); 241 222 let base = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap(); 242 223 let src = base.join("src");
+13 -12
compiler-core/src/config.rs
··· 1018 1018 pub source: Utf8PathBuf, 1019 1019 } 1020 1020 1021 - mod package_scoped_path { 1022 - use camino::{Utf8Component, Utf8PathBuf}; 1021 + pub(crate) mod package_scoped_path { 1022 + use camino::Utf8PathBuf; 1023 1023 use serde::{Deserialize, Deserializer, de::Error as _}; 1024 1024 1025 1025 pub fn deserialize<'de, D>(deserializer: D) -> Result<Utf8PathBuf, D::Error> ··· 1027 1027 D: Deserializer<'de>, 1028 1028 { 1029 1029 let path = Utf8PathBuf::deserialize(deserializer)?; 1030 - // Absolute paths are not permitted. 1031 - // On Windows paths starting with \\ are drive-relative, so absolute as 1032 - // far as we are concerned. 1033 - if path.is_absolute() || (cfg!(windows) && path.starts_with("\\")) { 1034 - return Err(D::Error::custom("paths must be relative")); 1035 - } 1036 - for component in path.components() { 1037 - if component == Utf8Component::ParentDir { 1038 - return Err(D::Error::custom("paths must not contain .. segments")); 1039 - } 1030 + crate::io::validate_safe_relative_path(&path).map_err(D::Error::custom)?; 1031 + Ok(path) 1032 + } 1033 + 1034 + pub fn optional_deserialize<'de, D>(deserializer: D) -> Result<Option<Utf8PathBuf>, D::Error> 1035 + where 1036 + D: Deserializer<'de>, 1037 + { 1038 + let path = Option::<Utf8PathBuf>::deserialize(deserializer)?; 1039 + if let Some(path) = &path { 1040 + crate::io::validate_safe_relative_path(path).map_err(D::Error::custom)?; 1040 1041 } 1041 1042 Ok(path) 1042 1043 }
+19 -1
compiler-core/src/io.rs
··· 17 17 }; 18 18 use tar::{Archive, Entry}; 19 19 20 - use camino::{Utf8Path, Utf8PathBuf}; 20 + use camino::{Utf8Component, Utf8Path, Utf8PathBuf}; 21 + 22 + /// Validates that a path is safe to use as a relative path. 23 + pub fn validate_safe_relative_path(path: &Utf8Path) -> Result<(), &'static str> { 24 + // Absolute paths are not permitted. 25 + // On Windows paths starting with \\ are drive-relative, so absolute as 26 + // far as we are concerned. 27 + if path.is_absolute() || (cfg!(windows) && path.starts_with("\\")) { 28 + return Err("paths must be relative"); 29 + } 30 + 31 + for component in path.components() { 32 + if component == Utf8Component::ParentDir { 33 + return Err("paths must not contain .. segments"); 34 + } 35 + } 36 + 37 + Ok(()) 38 + } 21 39 22 40 /// Takes in a source path and a target path and determines a relative path 23 41 /// from source -> target.
+46 -1
compiler-core/src/manifest.rs
··· 249 249 Git { 250 250 repo: EcoString, 251 251 commit: EcoString, 252 - #[serde(default, skip_serializing_if = "Option::is_none")] 252 + #[serde( 253 + default, 254 + skip_serializing_if = "Option::is_none", 255 + deserialize_with = "super::config::package_scoped_path::optional_deserialize" 256 + )] 253 257 path: Option<Utf8PathBuf>, 254 258 }, 255 259 #[serde(rename = "local")] ··· 312 316 ( 313 317 "awsome_local2".into(), 314 318 Requirement::git("https://github.com/gleam-lang/gleam.git", "bd9fe02f"), 319 + ), 320 + ( 321 + "awsome_local3".into(), 322 + Requirement::git_with_path( 323 + "https://github.com/gleam-lang/gleam.git", 324 + "bd9fe02f", 325 + "packages/sub", 326 + ), 315 327 ), 316 328 ( 317 329 "awsome_local1".into(), ··· 365 377 }, 366 378 }, 367 379 ManifestPackage { 380 + name: "awsome_local3".into(), 381 + version: Version::new(1, 2, 3), 382 + build_tools: ["gleam".into()].into(), 383 + otp_app: None, 384 + requirements: vec![], 385 + source: ManifestPackageSource::Git { 386 + repo: "https://github.com/gleam-lang/gleam.git".into(), 387 + commit: "bd9fe02f72250e6a136967917bcb1bdccaffa3c8".into(), 388 + path: Some("packages/sub".into()), 389 + }, 390 + }, 391 + ManifestPackage { 368 392 name: "awsome_local1".into(), 369 393 version: Version::new(1, 2, 3), 370 394 build_tools: ["gleam".into()].into(), ··· 402 426 { name = "aaa", version = "0.4.0", build_tools = ["rebar3", "make"], requirements = ["gleam_stdlib", "zzz"], otp_app = "aaa_app", source = "hex", outer_checksum = "0316" }, 403 427 { name = "awsome_local1", version = "1.2.3", build_tools = ["gleam"], requirements = [], source = "local", path = "../path/to/package" }, 404 428 { name = "awsome_local2", version = "1.2.3", build_tools = ["gleam"], requirements = [], source = "git", repo = "https://github.com/gleam-lang/gleam.git", commit = "bd9fe02f72250e6a136967917bcb1bdccaffa3c8" }, 429 + { name = "awsome_local3", version = "1.2.3", build_tools = ["gleam"], requirements = [], source = "git", repo = "https://github.com/gleam-lang/gleam.git", commit = "bd9fe02f72250e6a136967917bcb1bdccaffa3c8", path = "packages/sub" }, 405 430 { name = "gleam_stdlib", version = "0.17.1", build_tools = ["gleam"], requirements = [], source = "hex", outer_checksum = "0116" }, 406 431 { name = "gleeunit", version = "0.4.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], source = "hex", outer_checksum = "032E" }, 407 432 { name = "zzz", version = "0.4.0", build_tools = ["mix"], requirements = [], source = "hex", outer_checksum = "0316" }, ··· 411 436 aaa = { version = "> 0.0.0" } 412 437 awsome_local1 = { path = "../path/to/package" } 413 438 awsome_local2 = { git = "https://github.com/gleam-lang/gleam.git", ref = "bd9fe02f" } 439 + awsome_local3 = { git = "https://github.com/gleam-lang/gleam.git", ref = "bd9fe02f", path = "packages/sub" } 414 440 gleam_stdlib = { version = "~> 0.17" } 415 441 gleeunit = { version = "~> 0.1" } 416 442 zzz = { version = "> 0.0.0" } ··· 846 872 847 873 let manifest: Result<Manifest, _> = toml::from_str(toml); 848 874 let error = manifest.expect_err("should fail to deserialise because invalid name"); 875 + insta::assert_snapshot!(insta::internals::AutoName, error.to_string()); 876 + } 877 + 878 + #[test] 879 + fn git_package_with_escaping_path() { 880 + let toml = r#"# This file was generated by Gleam 881 + # You typically do not need to edit this file 882 + 883 + packages = [ 884 + { name = "wibble", version = "0.1.0", build_tools = ["gleam"], requirements = [], source = "git", repo = "https://github.com/gleam-lang/gleam.git", commit = "bd9fe02f72250e6a136967917bcb1bdccaffa3c8", path = "../escape" }, 885 + ] 886 + 887 + [requirements] 888 + wibble = { git = "https://github.com/gleam-lang/gleam.git", ref = "main", path = "wibble" } 889 + "#; 890 + 891 + let manifest: Result<Manifest, _> = toml::from_str(toml); 892 + let error = 893 + manifest.expect_err("should fail to deserialise because path escapes the repository"); 849 894 insta::assert_snapshot!(insta::internals::AutoName, error.to_string()); 850 895 } 851 896
+3 -3
compiler-core/src/paths.rs
··· 66 66 self.build_directory().join("packages") 67 67 } 68 68 69 - pub fn build_git_repos_directory(&self) -> Utf8PathBuf { 70 - self.build_directory().join("git_repos") 69 + pub fn build_git_directory(&self) -> Utf8PathBuf { 70 + self.build_directory().join("git") 71 71 } 72 72 73 73 pub fn build_git_repo(&self, name: &str) -> Utf8PathBuf { 74 - self.build_git_repos_directory().join(name) 74 + self.build_git_directory().join(name) 75 75 } 76 76 77 77 pub fn build_packages_toml(&self) -> Utf8PathBuf {
+19 -1
compiler-core/src/requirement.rs
··· 170 170 where 171 171 D: Deserializer<'de>, 172 172 { 173 - deserializer.deserialize_any(RequirementVisitor) 173 + let requirement = deserializer.deserialize_any(RequirementVisitor)?; 174 + if let Requirement::Git { 175 + path: Some(path), .. 176 + } = &requirement 177 + { 178 + crate::io::validate_safe_relative_path(path).map_err(de::Error::custom)?; 179 + } 180 + Ok(requirement) 174 181 } 175 182 } 176 183 ··· 206 213 207 214 let error = 208 215 toml::from_str::<HashMap<String, Requirement>>(toml).expect_err("invalid version"); 216 + insta::assert_snapshot!(error.to_string()); 217 + } 218 + 219 + #[test] 220 + fn read_git_requirement_with_escaping_path() { 221 + let toml = r#" 222 + monorepo = { git = "https://github.com/gleam-lang/gleam.git", ref = "main", path = "../escape" } 223 + "#; 224 + 225 + let error = 226 + toml::from_str::<HashMap<String, Requirement>>(toml).expect_err("escaping path"); 209 227 insta::assert_snapshot!(error.to_string()); 210 228 } 211 229 }
+9
compiler-core/src/snapshots/gleam_core__manifest__git_package_with_escaping_path.snap
··· 1 + --- 2 + source: compiler-core/src/manifest.rs 3 + expression: error.to_string() 4 + --- 5 + TOML parse error at line 5, column 3 6 + | 7 + 5 | { name = "wibble", version = "0.1.0", build_tools = ["gleam"], requirements = [], source = "git", repo = "https://github.com/gleam-lang/gleam.git", commit = "bd9fe02f72250e6a136967917bcb1bdccaffa3c8", path = "../escape" }, 8 + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 9 + paths must not contain .. segments
+9
compiler-core/src/snapshots/gleam_core__requirement__tests__read_git_requirement_with_escaping_path.snap
··· 1 + --- 2 + source: compiler-core/src/requirement.rs 3 + expression: error.to_string() 4 + --- 5 + TOML parse error at line 2, column 24 6 + | 7 + 2 | monorepo = { git = "https://github.com/gleam-lang/gleam.git", ref = "main", path = "../escape" } 8 + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 9 + paths must not contain .. segments
+55
test/project_git_deps_path/test.sh
··· 51 51 git init -q 52 52 git add . 53 53 git -c user.name="Test" -c user.email="test@example.com" commit -q -m "Initial commit" 54 + git branch -M main 54 55 REF=$(git rev-parse HEAD) 55 56 cd "$OLDPWD" 56 57 ··· 67 68 68 69 g update 69 70 g check 71 + 72 + echo 73 + echo Testing with a branch ref, two packages from one repo, and a clean rebuild 74 + rm -fr build 75 + 76 + cat > gleam.toml << EOF 77 + name = "git_deps_path" 78 + version = "0.1.0" 79 + 80 + [dependencies] 81 + package_a = { git = "file://${REPO_DIR}", ref = "main", path = "package_a" } 82 + package_b = { git = "file://${REPO_DIR}", ref = "main", path = "package_b" } 83 + EOF 84 + 85 + g update 86 + 87 + echo Checking that the hard-linked packages do not contain a .git entry 88 + if [ -e "build/packages/package_a/.git" ]; then 89 + echo "build/packages/package_a/.git should not exist" 90 + exit 1 91 + fi 92 + if [ -e "build/packages/package_b/.git" ]; then 93 + echo "build/packages/package_b/.git should not exist" 94 + exit 1 95 + fi 96 + 97 + EXPECTED_PACKAGE_B=" { name = \"package_b\", version = \"0.1.0\", build_tools = [\"gleam\"], requirements = [], source = \"git\", repo = \"file://${REPO_DIR}\", commit = \"${REF}\", path = \"package_b\" }," 98 + if ! grep -qFx "$EXPECTED_PACKAGE_B" manifest.toml; then 99 + echo "manifest.toml does not lock package_b as a git source. Expected:" 100 + echo "$EXPECTED_PACKAGE_B" 101 + echo "Got:" 102 + cat manifest.toml 103 + exit 1 104 + fi 105 + 106 + rm -fr build 107 + g check 108 + 109 + echo 110 + echo Testing that an unresolvable ref fails 111 + rm -fr build 112 + 113 + cat > gleam.toml << EOF 114 + name = "git_deps_path" 115 + version = "0.1.0" 116 + 117 + [dependencies] 118 + package_a = { git = "file://${REPO_DIR}", ref = "no-such-ref", path = "package_a" } 119 + EOF 120 + 121 + if g update; then 122 + echo "g update should have failed for an unresolvable ref" 123 + exit 1 124 + fi 70 125 71 126 echo 72 127 echo Success! 💖