| 1 | //! What the Composer registry needs that does not touch the network: |
| 2 | //! package names, versions read from tags and branches as Composer reads |
| 3 | //! them, the metadata Composer installs from, `export-ignore`, and zips. |
| 4 | //! |
| 5 | //! A Composer package is not uploaded: it is a repository of the workspace |
| 6 | //! with a `composer.json` at its root. Each tag that reads as a version is a |
| 7 | //! version, and each branch a `dev-` one, with the metadata of that ref's |
| 8 | //! `composer.json`, a git source on g1t.sh, and a zip of the commit as its |
| 9 | //! dist, made when it is first asked for. |
| 10 | |
| 11 | use serde_json::{Map, Value, json}; |
| 12 | |
| 13 | /// Composer's rule for a package name: `vendor/name`, lowercase, words |
| 14 | /// joined by `.`, `_` or `-`. |
| 15 | pub fn valid_name(name: &str) -> bool { |
| 16 | let Some((vendor, project)) = name.split_once('/') else { |
| 17 | return false; |
| 18 | }; |
| 19 | let part = |text: &str, double_dash: bool| { |
| 20 | let bytes = text.as_bytes(); |
| 21 | if bytes.is_empty() || !bytes[0].is_ascii_alphanumeric() || !bytes[bytes.len() - 1].is_ascii_alphanumeric() { |
| 22 | return false; |
| 23 | } |
| 24 | let mut run = String::new(); |
| 25 | for byte in bytes { |
| 26 | if byte.is_ascii_lowercase() || byte.is_ascii_digit() { |
| 27 | if !run.is_empty() && !(run == "." || run == "_" || run == "-" || (double_dash && run == "--")) { |
| 28 | return false; |
| 29 | } |
| 30 | run.clear(); |
| 31 | } else if matches!(byte, b'.' | b'_' | b'-') { |
| 32 | run.push(*byte as char); |
| 33 | } else { |
| 34 | return false; |
| 35 | } |
| 36 | } |
| 37 | true |
| 38 | }; |
| 39 | !project.contains('/') && part(vendor, false) && part(project, true) && name.len() <= 200 |
| 40 | } |
| 41 | |
| 42 | /// A version Composer can install, as Packagist names it: the tag or |
| 43 | /// branch's own `version`, and `version_normalized` to compare by. |
| 44 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 45 | pub struct Version { |
| 46 | pub version: String, |
| 47 | pub normalized: String, |
| 48 | } |
| 49 | |
| 50 | impl Version { |
| 51 | pub fn is_dev(&self) -> bool { |
| 52 | self.normalized.starts_with("dev-") || self.normalized.ends_with("-dev") |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | /// The stability words Composer reads after a version, as it writes them. |
| 57 | fn stability(word: &str) -> Option<&'static str> { |
| 58 | match word.to_ascii_lowercase().as_str() { |
| 59 | "stable" => Some(""), |
| 60 | "beta" | "b" => Some("beta"), |
| 61 | "rc" => Some("RC"), |
| 62 | "alpha" | "a" => Some("alpha"), |
| 63 | "patch" | "pl" | "p" => Some("patch"), |
| 64 | _ => None, |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | /// A tag's version, the way Composer's version parser reads it: |
| 69 | /// `v1.2.3`, `1.2`, `1.0.0-beta.2`, `2.0.0-RC1`. Anything else is not a |
| 70 | /// version, and its tag is left out. |
| 71 | pub fn tag_version(tag: &str) -> Option<Version> { |
| 72 | let text = tag.strip_prefix('v').or_else(|| tag.strip_prefix('V')).unwrap_or(tag); |
| 73 | let digits_end = text.find(|c: char| !(c.is_ascii_digit() || c == '.')).unwrap_or(text.len()); |
| 74 | let (numbers, rest) = text.split_at(digits_end); |
| 75 | let numbers = numbers.trim_end_matches('.'); |
| 76 | let parts: Vec<&str> = numbers.split('.').collect(); |
| 77 | if parts.is_empty() || parts.len() > 4 || parts.iter().any(|p| p.is_empty() || p.len() > 9) { |
| 78 | return None; |
| 79 | } |
| 80 | if numbers.len() != text[..digits_end].len() && !rest.is_empty() { |
| 81 | // `1.2.` followed by more is not a version. |
| 82 | return None; |
| 83 | } |
| 84 | let mut normalized: Vec<String> = parts.iter().map(|p| p.trim_start_matches('0').to_owned()).map(|p| if p.is_empty() { "0".into() } else { p }).collect(); |
| 85 | while normalized.len() < 4 { |
| 86 | normalized.push("0".into()); |
| 87 | } |
| 88 | let mut normalized = normalized.join("."); |
| 89 | let rest = rest.trim_start_matches(['-', '_', '.']); |
| 90 | if !rest.is_empty() { |
| 91 | let (word, tail) = rest.split_at(rest.find(|c: char| !c.is_ascii_alphabetic()).unwrap_or(rest.len())); |
| 92 | let word = stability(word)?; |
| 93 | let number = tail.trim_start_matches(['-', '.']); |
| 94 | let (number, dev) = match number.strip_suffix("dev") { |
| 95 | Some(n) => (n.trim_end_matches(['-', '.']), true), |
| 96 | None => (number, false), |
| 97 | }; |
| 98 | if !number.bytes().all(|b| b.is_ascii_digit() || b == b'.') { |
| 99 | return None; |
| 100 | } |
| 101 | if !word.is_empty() { |
| 102 | normalized.push('-'); |
| 103 | normalized.push_str(word); |
| 104 | normalized.push_str(&number.replace('.', "")); |
| 105 | } |
| 106 | if dev { |
| 107 | normalized.push_str("-dev"); |
| 108 | } |
| 109 | } |
| 110 | Some(Version { version: tag.to_owned(), normalized }) |
| 111 | } |
| 112 | |
| 113 | /// A branch's version: `dev-<branch>`, or for a branch named like a |
| 114 | /// version (`1.x`, `2.1`) that version's `-dev`, as Composer reads them. |
| 115 | pub fn branch_version(branch: &str) -> Version { |
| 116 | let text = branch.strip_prefix('v').unwrap_or(branch); |
| 117 | let parts: Vec<&str> = text.split('.').collect(); |
| 118 | let numeric = !text.is_empty() |
| 119 | && parts.len() <= 4 |
| 120 | && parts[0].bytes().all(|b| b.is_ascii_digit()) |
| 121 | && !parts[0].is_empty() |
| 122 | && parts.iter().all(|p| !p.is_empty() && (p.bytes().all(|b| b.is_ascii_digit()) || matches!(*p, "x" | "X" | "*"))); |
| 123 | if !numeric { |
| 124 | return Version { version: format!("dev-{branch}"), normalized: format!("dev-{branch}") }; |
| 125 | } |
| 126 | let mut nines: Vec<String> = parts |
| 127 | .iter() |
| 128 | .map(|p| if p.bytes().all(|b| b.is_ascii_digit()) { (*p).to_owned() } else { "9999999".to_owned() }) |
| 129 | .collect(); |
| 130 | while nines.len() < 4 { |
| 131 | nines.push("9999999".to_owned()); |
| 132 | } |
| 133 | let version = if parts.last().is_some_and(|p| matches!(*p, "x" | "X" | "*")) { |
| 134 | format!("{branch}-dev") |
| 135 | } else { |
| 136 | format!("{branch}.x-dev") |
| 137 | }; |
| 138 | Version { version, normalized: format!("{}-dev", nines.join(".")) } |
| 139 | } |
| 140 | |
| 141 | /// The fields of `composer.json` a version's metadata keeps, as Packagist |
| 142 | /// serves them. |
| 143 | const KEPT: [&str; 25] = [ |
| 144 | "name", |
| 145 | "description", |
| 146 | "keywords", |
| 147 | "homepage", |
| 148 | "readme", |
| 149 | "license", |
| 150 | "authors", |
| 151 | "type", |
| 152 | "support", |
| 153 | "funding", |
| 154 | "autoload", |
| 155 | "autoload-dev", |
| 156 | "extra", |
| 157 | "bin", |
| 158 | "include-path", |
| 159 | "target-dir", |
| 160 | "require", |
| 161 | "require-dev", |
| 162 | "suggest", |
| 163 | "provide", |
| 164 | "replace", |
| 165 | "conflict", |
| 166 | "archive", |
| 167 | "abandoned", |
| 168 | "notification-url", |
| 169 | ]; |
| 170 | |
| 171 | /// Where one version installs from. |
| 172 | pub struct Origin<'a> { |
| 173 | /// `https://g1t.sh/acme/lib.git`. |
| 174 | pub git_url: &'a str, |
| 175 | /// `https://g1t.sh/-/composer/acme/dist/acme/lib/<commit>.zip`. |
| 176 | pub dist_url: &'a str, |
| 177 | pub commit: &'a str, |
| 178 | /// Set on the default branch's version, as Composer 2 marks it. |
| 179 | pub default_branch: bool, |
| 180 | } |
| 181 | |
| 182 | /// A version's metadata: its `composer.json`, kept fields only, named |
| 183 | /// `name`, with its version, source and dist. |
| 184 | pub fn version_entry(composer: &Value, name: &str, version: &Version, origin: &Origin<'_>) -> Value { |
| 185 | let mut entry = Map::new(); |
| 186 | if let Value::Object(fields) = composer { |
| 187 | for key in KEPT { |
| 188 | if let Some(value) = fields.get(key) { |
| 189 | entry.insert(key.to_owned(), value.clone()); |
| 190 | } |
| 191 | } |
| 192 | } |
| 193 | // A single license is a list of one, as Packagist writes it. |
| 194 | if let Some(Value::String(license)) = entry.get("license") { |
| 195 | let license = license.clone(); |
| 196 | entry.insert("license".into(), json!([license])); |
| 197 | } |
| 198 | entry.entry("type").or_insert(json!("library")); |
| 199 | entry.insert("name".into(), json!(name)); |
| 200 | entry.insert("version".into(), json!(version.version)); |
| 201 | entry.insert("version_normalized".into(), json!(version.normalized)); |
| 202 | entry.insert("source".into(), json!({ "type": "git", "url": origin.git_url, "reference": origin.commit })); |
| 203 | entry.insert( |
| 204 | "dist".into(), |
| 205 | json!({ "type": "zip", "url": origin.dist_url, "reference": origin.commit, "shasum": "" }), |
| 206 | ); |
| 207 | if origin.default_branch { |
| 208 | entry.insert("default-branch".into(), json!(true)); |
| 209 | } |
| 210 | Value::Object(entry) |
| 211 | } |
| 212 | |
| 213 | /// `p2/<vendor>/<name>.json`: every version, as Composer reads them |
| 214 | /// (not minified: each entry whole). |
| 215 | pub fn p2(name: &str, versions: &[Value]) -> Value { |
| 216 | json!({ "packages": { name: versions } }) |
| 217 | } |
| 218 | |
| 219 | /// `packages.json` of a workspace. |
| 220 | pub fn root(workspace: &str, available: &[String]) -> Value { |
| 221 | json!({ |
| 222 | "packages": [], |
| 223 | "metadata-url": format!("/-/composer/{workspace}/p2/%package%.json"), |
| 224 | "available-packages": available, |
| 225 | "notify-batch": format!("/-/composer/{workspace}/downloads"), |
| 226 | }) |
| 227 | } |
| 228 | |
| 229 | /// Whether `pattern` matches `text`, `*` any run of characters but `/`, |
| 230 | /// `?` any one. |
| 231 | fn glob(pattern: &[u8], text: &[u8]) -> bool { |
| 232 | match (pattern.first(), text.first()) { |
| 233 | (None, None) => true, |
| 234 | (Some(b'*'), _) => { |
| 235 | glob(&pattern[1..], text) || (!text.is_empty() && text[0] != b'/' && glob(pattern, &text[1..])) |
| 236 | } |
| 237 | (Some(b'?'), Some(c)) if *c != b'/' => glob(&pattern[1..], &text[1..]), |
| 238 | (Some(p), Some(c)) if p == c => glob(&pattern[1..], &text[1..]), |
| 239 | _ => false, |
| 240 | } |
| 241 | } |
| 242 | |
| 243 | /// The patterns a `.gitattributes` marks `export-ignore`. |
| 244 | pub fn export_ignores(gitattributes: &str) -> Vec<String> { |
| 245 | gitattributes |
| 246 | .lines() |
| 247 | .filter_map(|line| { |
| 248 | let line = line.trim(); |
| 249 | if line.starts_with('#') { |
| 250 | return None; |
| 251 | } |
| 252 | let mut words = line.split_whitespace(); |
| 253 | let pattern = words.next()?; |
| 254 | words.any(|attr| attr == "export-ignore").then(|| pattern.to_owned()) |
| 255 | }) |
| 256 | .collect() |
| 257 | } |
| 258 | |
| 259 | /// Whether a file is left out of an archive by an `export-ignore` |
| 260 | /// pattern: a pattern without `/` matches a name at any depth, one with a |
| 261 | /// `/` matches from the root, and a directory's pattern everything in it. |
| 262 | pub fn ignored(patterns: &[String], path: &str) -> bool { |
| 263 | patterns.iter().any(|pattern| { |
| 264 | let (pattern, dir_only) = match pattern.strip_suffix('/') { |
| 265 | Some(p) => (p, true), |
| 266 | None => (pattern.as_str(), false), |
| 267 | }; |
| 268 | let anchored = pattern.contains('/'); |
| 269 | let pattern = pattern.trim_start_matches('/'); |
| 270 | let parts: Vec<&str> = path.split('/').collect(); |
| 271 | // Each directory the file is in, and (unless only directories |
| 272 | // match) the file itself. |
| 273 | let candidates = (1..=parts.len()).filter(|n| !dir_only || *n < parts.len()); |
| 274 | for n in candidates { |
| 275 | let prefix = parts[..n].join("/"); |
| 276 | let subject = if anchored { prefix.as_str() } else { parts[n - 1] }; |
| 277 | if glob(pattern.as_bytes(), subject.as_bytes()) { |
| 278 | return true; |
| 279 | } |
| 280 | } |
| 281 | false |
| 282 | }) |
| 283 | } |
| 284 | |
| 285 | /// CRC-32, as zip records each file's. |
| 286 | fn crc32(bytes: &[u8]) -> u32 { |
| 287 | let mut table = [0u32; 256]; |
| 288 | for (i, entry) in table.iter_mut().enumerate() { |
| 289 | let mut c = i as u32; |
| 290 | for _ in 0..8 { |
| 291 | c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 }; |
| 292 | } |
| 293 | *entry = c; |
| 294 | } |
| 295 | let mut crc = 0xFFFF_FFFFu32; |
| 296 | for byte in bytes { |
| 297 | crc = table[((crc ^ u32::from(*byte)) & 0xFF) as usize] ^ (crc >> 8); |
| 298 | } |
| 299 | !crc |
| 300 | } |
| 301 | |
| 302 | /// A zip of `files` (path and bytes), deflated where that is smaller. No |
| 303 | /// directory entries, a fixed time, so the same files make the same zip. |
| 304 | pub fn zip(files: &[(String, Vec<u8>)]) -> Vec<u8> { |
| 305 | let mut out = Vec::new(); |
| 306 | let mut central = Vec::new(); |
| 307 | for (path, data) in files { |
| 308 | let crc = crc32(data); |
| 309 | let deflated = miniz_oxide::deflate::compress_to_vec(data, 6); |
| 310 | let (method, body): (u16, &[u8]) = if deflated.len() < data.len() { (8, &deflated) } else { (0, data) }; |
| 311 | let offset = out.len() as u32; |
| 312 | let name = path.as_bytes(); |
| 313 | let header = |sig: u32, central_entry: bool| { |
| 314 | let mut h = Vec::with_capacity(46 + name.len()); |
| 315 | h.extend_from_slice(&sig.to_le_bytes()); |
| 316 | if central_entry { |
| 317 | h.extend_from_slice(&0x031Eu16.to_le_bytes()); // made by: Unix, 3.0 |
| 318 | } |
| 319 | h.extend_from_slice(&20u16.to_le_bytes()); // version needed |
| 320 | h.extend_from_slice(&0x0800u16.to_le_bytes()); // UTF-8 names |
| 321 | h.extend_from_slice(&method.to_le_bytes()); |
| 322 | h.extend_from_slice(&0u16.to_le_bytes()); // time |
| 323 | h.extend_from_slice(&0x0021u16.to_le_bytes()); // date: 1980-01-01 |
| 324 | h.extend_from_slice(&crc.to_le_bytes()); |
| 325 | h.extend_from_slice(&(body.len() as u32).to_le_bytes()); |
| 326 | h.extend_from_slice(&(data.len() as u32).to_le_bytes()); |
| 327 | h.extend_from_slice(&(name.len() as u16).to_le_bytes()); |
| 328 | h.extend_from_slice(&0u16.to_le_bytes()); // extra |
| 329 | if central_entry { |
| 330 | h.extend_from_slice(&0u16.to_le_bytes()); // comment |
| 331 | h.extend_from_slice(&0u16.to_le_bytes()); // disk |
| 332 | h.extend_from_slice(&0u16.to_le_bytes()); // internal attributes |
| 333 | h.extend_from_slice(&(0o100644u32 << 16).to_le_bytes()); |
| 334 | h.extend_from_slice(&offset.to_le_bytes()); |
| 335 | } |
| 336 | h.extend_from_slice(name); |
| 337 | h |
| 338 | }; |
| 339 | out.extend_from_slice(&header(0x0403_4b50, false)); |
| 340 | out.extend_from_slice(body); |
| 341 | central.extend_from_slice(&header(0x0201_4b50, true)); |
| 342 | } |
| 343 | let central_offset = out.len() as u32; |
| 344 | out.extend_from_slice(¢ral); |
| 345 | out.extend_from_slice(&0x0605_4b50u32.to_le_bytes()); |
| 346 | out.extend_from_slice(&[0, 0, 0, 0]); // disks |
| 347 | out.extend_from_slice(&(files.len() as u16).to_le_bytes()); |
| 348 | out.extend_from_slice(&(files.len() as u16).to_le_bytes()); |
| 349 | out.extend_from_slice(&(central.len() as u32).to_le_bytes()); |
| 350 | out.extend_from_slice(¢ral_offset.to_le_bytes()); |
| 351 | out.extend_from_slice(&0u16.to_le_bytes()); |
| 352 | out |
| 353 | } |
| 354 | |
| 355 | #[cfg(test)] |
| 356 | mod tests { |
| 357 | use super::*; |
| 358 | |
| 359 | #[test] |
| 360 | fn names_follow_composers_rule() { |
| 361 | for good in ["acme/lib", "acme-co/http.client", "a1/b_2", "acme/my--lib"] { |
| 362 | assert!(valid_name(good), "{good}"); |
| 363 | } |
| 364 | for bad in ["acme", "Acme/lib", "acme/lib/x", "-acme/lib", "acme/lib-", "acme/l b", "acme/a..b", "ac--me/lib"] { |
| 365 | assert!(!valid_name(bad), "{bad}"); |
| 366 | } |
| 367 | } |
| 368 | |
| 369 | #[test] |
| 370 | fn tags_read_as_composer_reads_them() { |
| 371 | let v = |tag: &str| tag_version(tag).map(|v| v.normalized); |
| 372 | assert_eq!(v("v1.2.3").as_deref(), Some("1.2.3.0")); |
| 373 | assert_eq!(v("1.2").as_deref(), Some("1.2.0.0")); |
| 374 | assert_eq!(v("2.0.0-RC1").as_deref(), Some("2.0.0.0-RC1")); |
| 375 | assert_eq!(v("1.0.0-beta.2").as_deref(), Some("1.0.0.0-beta2")); |
| 376 | assert_eq!(v("1.0.0-alpha").as_deref(), Some("1.0.0.0-alpha")); |
| 377 | assert_eq!(v("1.0.0-p1").as_deref(), Some("1.0.0.0-patch1")); |
| 378 | assert_eq!(v("1.0.0-stable").as_deref(), Some("1.0.0.0")); |
| 379 | assert_eq!(v("01.02.003").as_deref(), Some("1.2.3.0")); |
| 380 | assert_eq!(tag_version("v1.0.0").unwrap().version, "v1.0.0", "the tag's own name is the version"); |
| 381 | for bad in ["latest", "release-1", "1.0.0-nope", "1.2.3.4.5", "v", ""] { |
| 382 | assert_eq!(tag_version(bad), None, "{bad}"); |
| 383 | } |
| 384 | assert!(!tag_version("1.0.0").unwrap().is_dev()); |
| 385 | } |
| 386 | |
| 387 | #[test] |
| 388 | fn branches_are_dev_versions() { |
| 389 | assert_eq!(branch_version("main"), Version { version: "dev-main".into(), normalized: "dev-main".into() }); |
| 390 | assert_eq!( |
| 391 | branch_version("1.x"), |
| 392 | Version { version: "1.x-dev".into(), normalized: "1.9999999.9999999.9999999-dev".into() } |
| 393 | ); |
| 394 | assert_eq!( |
| 395 | branch_version("2.1"), |
| 396 | Version { version: "2.1.x-dev".into(), normalized: "2.1.9999999.9999999-dev".into() } |
| 397 | ); |
| 398 | assert_eq!(branch_version("feature/x").version, "dev-feature/x"); |
| 399 | assert!(branch_version("main").is_dev()); |
| 400 | } |
| 401 | |
| 402 | #[test] |
| 403 | fn a_versions_metadata_is_its_composer_json_with_where_it_installs_from() { |
| 404 | let composer = json!({ |
| 405 | "name": "acme/lib", |
| 406 | "description": "A library", |
| 407 | "license": "MIT", |
| 408 | "require": { "php": ">=8.1" }, |
| 409 | "autoload": { "psr-4": { "Acme\\Lib\\": "src/" } }, |
| 410 | "scripts": { "test": "phpunit" }, |
| 411 | "config": { "sort-packages": true }, |
| 412 | }); |
| 413 | let version = tag_version("v1.0.0").unwrap(); |
| 414 | let origin = Origin { |
| 415 | git_url: "https://g1t.sh/acme/lib.git", |
| 416 | dist_url: "https://g1t.sh/-/composer/acme/dist/acme/lib/abc.zip", |
| 417 | commit: "abc", |
| 418 | default_branch: false, |
| 419 | }; |
| 420 | let entry = version_entry(&composer, "acme/lib", &version, &origin); |
| 421 | assert_eq!(entry["version"], "v1.0.0"); |
| 422 | assert_eq!(entry["version_normalized"], "1.0.0.0"); |
| 423 | assert_eq!(entry["license"], json!(["MIT"])); |
| 424 | assert_eq!(entry["type"], "library"); |
| 425 | assert_eq!(entry["require"]["php"], ">=8.1"); |
| 426 | assert_eq!(entry["autoload"]["psr-4"]["Acme\\Lib\\"], "src/"); |
| 427 | assert_eq!(entry["source"], json!({ "type": "git", "url": "https://g1t.sh/acme/lib.git", "reference": "abc" })); |
| 428 | assert_eq!(entry["dist"]["type"], "zip"); |
| 429 | assert_eq!(entry["dist"]["reference"], "abc"); |
| 430 | assert!(entry.get("scripts").is_none(), "only what installs"); |
| 431 | assert!(entry.get("config").is_none()); |
| 432 | assert!(entry.get("default-branch").is_none()); |
| 433 | let p2 = p2("acme/lib", &[entry]); |
| 434 | assert_eq!(p2["packages"]["acme/lib"][0]["version"], "v1.0.0"); |
| 435 | let root = root("acme", &["acme/lib".to_owned()]); |
| 436 | assert_eq!(root["metadata-url"], "/-/composer/acme/p2/%package%.json"); |
| 437 | assert_eq!(root["available-packages"], json!(["acme/lib"])); |
| 438 | } |
| 439 | |
| 440 | #[test] |
| 441 | fn export_ignore_leaves_files_out_as_git_archive_does() { |
| 442 | let patterns = export_ignores("# dev only\n/tests export-ignore\n.github/ export-ignore\n*.md export-ignore\n/phpunit.xml.dist export-ignore\nsrc/* text\n"); |
| 443 | assert_eq!(patterns, ["/tests", ".github/", "*.md", "/phpunit.xml.dist"]); |
| 444 | assert!(ignored(&patterns, "tests/LibTest.php")); |
| 445 | assert!(ignored(&patterns, ".github/workflows/ci.yml")); |
| 446 | assert!(ignored(&patterns, "README.md")); |
| 447 | assert!(ignored(&patterns, "docs/guide.md")); |
| 448 | assert!(ignored(&patterns, "phpunit.xml.dist")); |
| 449 | assert!(!ignored(&patterns, "src/Lib.php")); |
| 450 | assert!(!ignored(&patterns, "src/tests/Helper.php"), "/tests is anchored at the root"); |
| 451 | assert!(!ignored(&patterns, "composer.json")); |
| 452 | } |
| 453 | |
| 454 | #[test] |
| 455 | fn a_zip_holds_its_files_and_their_checksums() { |
| 456 | assert_eq!(crc32(b"123456789"), 0xCBF4_3926); |
| 457 | let files = vec![ |
| 458 | ("composer.json".to_owned(), br#"{"name":"acme/lib"}"#.to_vec()), |
| 459 | ("src/Lib.php".to_owned(), "<?php\n".repeat(100).into_bytes()), |
| 460 | ]; |
| 461 | let zip = zip(&files); |
| 462 | assert_eq!(&zip[..4], &0x0403_4b50u32.to_le_bytes()); |
| 463 | // The end record counts both files. |
| 464 | let end = &zip[zip.len() - 22..]; |
| 465 | assert_eq!(&end[..4], &0x0605_4b50u32.to_le_bytes()); |
| 466 | assert_eq!(u16::from_le_bytes([end[10], end[11]]), 2); |
| 467 | // The repetitive file was deflated, the short one stored. |
| 468 | assert!(zip.len() < 600, "{}", zip.len()); |
| 469 | assert_eq!(super::zip(&files), zip, "the same files make the same zip"); |
| 470 | } |
| 471 | } |