1use std::collections::HashMap;
36use std::io::Read;
37use std::path::Path;
38
39use crate::error::{Result, ToolchainError};
40use crate::recipe::{InstallPlan, InstallStep, ResourceSpec};
41
42#[derive(Debug, Clone, PartialEq)]
44pub struct ChocoRecipe {
45 pub id: String,
47 pub version: String,
49 pub plan: InstallPlan,
51}
52
53pub fn parse_nupkg(bytes: &[u8], prefix: &Path) -> Result<ChocoRecipe> {
62 let cursor = std::io::Cursor::new(bytes);
63 let mut archive = zip::ZipArchive::new(cursor).map_err(|e| ToolchainError::RegistryError {
64 message: format!("failed to open .nupkg as a zip archive: {e}"),
65 })?;
66 let names: Vec<String> = archive.file_names().map(str::to_string).collect();
67 let nuspec_name = names
68 .iter()
69 .find(|n| is_root_nuspec(n))
70 .cloned()
71 .ok_or_else(|| ToolchainError::RegistryError {
72 message: "no *.nuspec found at the .nupkg archive root".to_string(),
73 })?;
74 let nuspec = read_zip_text(&mut archive, &nuspec_name)?;
75 let (id, version) = parse_nuspec(&nuspec)?;
76 let ps1_name = names
77 .iter()
78 .find(|n| is_install_ps1(n))
79 .cloned()
80 .ok_or_else(|| ToolchainError::RegistryError {
81 message: format!("no tools/chocolateyInstall.ps1 in .nupkg for {id}"),
82 })?;
83 let ps1 = read_zip_text(&mut archive, &ps1_name)?;
84 let plan = plan_from_ps1(&ps1, &id, &version, prefix);
85 Ok(ChocoRecipe { id, version, plan })
86}
87
88fn is_root_nuspec(name: &str) -> bool {
90 !name.contains('/')
91 && !name.contains('\\')
92 && Path::new(&name.to_ascii_lowercase())
93 .extension()
94 .is_some_and(|e| e == "nuspec")
95}
96
97fn is_install_ps1(name: &str) -> bool {
100 name.to_ascii_lowercase().replace('\\', "/") == "tools/chocolateyinstall.ps1"
101}
102
103fn read_zip_text(
105 archive: &mut zip::ZipArchive<std::io::Cursor<&[u8]>>,
106 name: &str,
107) -> Result<String> {
108 let mut file = archive
109 .by_name(name)
110 .map_err(|e| ToolchainError::RegistryError {
111 message: format!("failed to read `{name}` from .nupkg: {e}"),
112 })?;
113 let mut bytes = Vec::new();
114 file.read_to_end(&mut bytes)?;
115 let text = String::from_utf8_lossy(&bytes).into_owned();
116 Ok(text.trim_start_matches('\u{feff}').to_string())
117}
118
119fn parse_nuspec(xml: &str) -> Result<(String, String)> {
126 let meta = xml_region(xml, "metadata").unwrap_or(xml);
127 let id = xml_region(meta, "id")
128 .map(str::trim)
129 .filter(|s| !s.is_empty());
130 let version = xml_region(meta, "version")
131 .map(str::trim)
132 .filter(|s| !s.is_empty());
133 match (id, version) {
134 (Some(id), Some(version)) => Ok((id.to_string(), version.to_string())),
135 _ => Err(ToolchainError::RegistryError {
136 message: "nuspec is missing <id> or <version> inside <metadata>".to_string(),
137 }),
138 }
139}
140
141fn xml_region<'a>(xml: &'a str, tag: &str) -> Option<&'a str> {
144 let lower = xml.to_ascii_lowercase();
145 let open = format!("<{tag}");
146 let close = format!("</{tag}");
147 let mut search = 0;
148 while let Some(rel) = lower[search..].find(&open) {
149 let after = search + rel + open.len();
150 if !matches!(
151 lower.as_bytes().get(after),
152 Some(b'>' | b' ' | b'\t' | b'\r' | b'\n')
153 ) {
154 search = after;
155 continue;
156 }
157 let gt = lower[after..].find('>')? + after;
158 if lower.as_bytes()[gt - 1] == b'/' {
159 search = gt + 1;
160 continue; }
162 let end = lower[gt + 1..].find(&close)? + gt + 1;
163 return Some(&xml[gt + 1..end]);
164 }
165 None
166}
167
168struct Download {
174 url: String,
176 sha256: String,
178 weak: Option<String>,
180}
181
182struct PsPlanner {
184 pkg_id: String,
186 prefix: String,
188 vars: HashMap<String, String>,
190 tables: HashMap<String, HashMap<String, Option<String>>>,
193 steps: Vec<InstallStep>,
195 resources: Vec<ResourceSpec>,
197}
198
199fn plan_from_ps1(ps1: &str, id: &str, version: &str, prefix: &Path) -> InstallPlan {
202 let mut planner = PsPlanner::new(id, version, prefix);
203 let lines: Vec<&str> = ps1.lines().collect();
204 let mut i = 0usize;
205 while i < lines.len() {
206 let raw = lines[i].trim_start_matches('\u{feff}').trim();
207 i += 1;
208 if raw.starts_with("<#") {
209 let mut cur = raw;
211 while !cur.contains("#>") && i < lines.len() {
212 cur = lines[i];
213 i += 1;
214 }
215 continue;
216 }
217 let line = strip_ps_comment(raw);
218 let line = line.trim();
219 if line.is_empty() {
220 continue;
221 }
222 planner.dispatch_line(line, &lines, &mut i);
223 }
224 planner.finish()
225}
226
227impl PsPlanner {
228 fn new(id: &str, version: &str, prefix: &Path) -> Self {
231 let prefix = prefix.display().to_string();
232 let mut vars = HashMap::new();
233 vars.insert("packagename".to_string(), id.to_string());
234 vars.insert("packageversion".to_string(), version.to_string());
235 vars.insert("packagefolder".to_string(), prefix.clone());
236 Self {
237 pkg_id: id.to_string(),
238 prefix,
239 vars,
240 tables: HashMap::new(),
241 steps: Vec::new(),
242 resources: Vec::new(),
243 }
244 }
245
246 fn unsupported(&mut self, construct: &str) {
248 self.steps.push(InstallStep::Unsupported {
249 construct: construct.to_string(),
250 });
251 }
252
253 fn unsupported_block(&mut self, line: &str, lines: &[&str], i: &mut usize) {
256 self.unsupported(line);
257 let mut delta = line_brace_delta(line);
258 while delta > 0 && *i < lines.len() {
259 let next = strip_ps_comment(lines[*i].trim());
260 *i += 1;
261 delta += line_brace_delta(&next);
262 }
263 }
264
265 fn dispatch_line(&mut self, line: &str, lines: &[&str], i: &mut usize) {
267 if let Some((name, rhs)) = parse_assignment(line) {
268 self.assignment(line, &name, rhs, lines, i);
269 return;
270 }
271 if is_inert(line) {
272 return;
273 }
274 let tokens = tokenize_ps(line);
275 let Some(cmd) = tokens.first().cloned() else {
276 return;
277 };
278 match cmd.to_ascii_lowercase().as_str() {
279 "install-chocolateyzippackage" => self.cmd_zip_package(&tokens[1..], line),
280 "get-chocolateywebfile" => self.cmd_web_file(&tokens[1..], line),
281 "install-chocolateypackage" | "install-chocolateyinstallpackage" => {
282 self.cmd_loud_installer(&cmd, &tokens[1..], line);
283 }
284 "install-binfile" => self.cmd_bin_file(&tokens[1..], line),
285 _ => self.unsupported_block(line, lines, i),
286 }
287 }
288
289 fn assignment(&mut self, line: &str, name: &str, rhs: &str, lines: &[&str], i: &mut usize) {
291 if name == "erroractionpreference" {
292 return; }
294 if let Some(after_brace) = rhs.strip_prefix("@{") {
295 match collect_hashtable_body(after_brace, lines, i) {
296 Some(body) => {
297 let (map, junk) = self.parse_table(&body);
298 for entry in junk {
299 self.unsupported(&entry);
300 }
301 self.tables.insert(name.to_string(), map);
302 }
303 None => self.unsupported(line),
304 }
305 return;
306 }
307 match self.resolve_rhs(rhs) {
308 Some(value) => {
309 self.vars.insert(name.to_string(), value);
310 }
311 None => self.unsupported_block(line, lines, i),
312 }
313 }
314
315 fn parse_table(&self, body: &str) -> (HashMap<String, Option<String>>, Vec<String>) {
318 let mut map = HashMap::new();
319 let mut junk = Vec::new();
320 for line in body.lines() {
321 for entry in split_semis(line) {
322 let entry = entry.trim();
323 if entry.is_empty() {
324 continue;
325 }
326 match split_kv(entry) {
327 Some((key, value)) => {
328 map.insert(normalize_key(key), self.resolve_rhs(value));
329 }
330 None => junk.push(entry.to_string()),
331 }
332 }
333 }
334 (map, junk)
335 }
336
337 fn cmd_zip_package(&mut self, args: &[String], line: &str) {
342 let args =
343 self.gather_call_args(args, &["packagename", "url", "unziplocation", "url64bit"]);
344 match pick_download(&args) {
345 Some(dl) => self.push_download(dl, true),
346 None => self.unsupported(line),
347 }
348 }
349
350 fn cmd_web_file(&mut self, args: &[String], line: &str) {
353 let args = self.gather_call_args(args, &["packagename", "filefullpath", "url", "url64bit"]);
354 match pick_download(&args) {
355 Some(dl) => self.push_download(dl, false),
356 None => self.unsupported(line),
357 }
358 }
359
360 fn cmd_loud_installer(&mut self, cmd: &str, args: &[String], line: &str) {
364 let args = self.gather_call_args(args, &["packagename", "filetype", "silentargs", "url"]);
365 let url = args
366 .get("url64bit")
367 .or_else(|| args.get("url"))
368 .and_then(Clone::clone);
369 let construct = url.map_or_else(|| line.to_string(), |u| format!("{cmd} {u}"));
370 self.unsupported(&construct);
371 }
372
373 fn cmd_bin_file(&mut self, args: &[String], line: &str) {
375 let args = self.gather_call_args(args, &["name", "path"]);
376 let name = args.get("name").and_then(Clone::clone);
377 let path = args.get("path").and_then(Clone::clone);
378 match (name, path) {
379 (Some(name), Some(path)) => {
380 let from = self.relativize(&path);
381 self.steps.push(InstallStep::BinInstall {
382 from,
383 to: Some(name),
384 });
385 }
386 _ => self.unsupported(line),
387 }
388 }
389
390 fn push_download(&mut self, dl: Download, extract_to_prefix: bool) {
393 let name = self.unique_resource_name();
394 self.resources.push(ResourceSpec {
395 name: name.clone(),
396 url: dl.url,
397 sha256: dl.sha256,
398 stage_to: None,
399 });
400 self.steps
401 .push(InstallStep::StageResource { name, dir: None });
402 if extract_to_prefix {
403 self.steps.push(InstallStep::PrefixInstall {
404 from: ".".to_string(),
405 to: None,
406 });
407 }
408 if let Some(weak) = dl.weak {
409 self.unsupported(&weak);
410 }
411 }
412
413 fn unique_resource_name(&self) -> String {
415 let taken = |candidate: &str| self.resources.iter().any(|r| r.name == candidate);
416 if !taken(&self.pkg_id) {
417 return self.pkg_id.clone();
418 }
419 let mut n = 2usize;
420 loop {
421 let candidate = format!("{}-{n}", self.pkg_id);
422 if !taken(&candidate) {
423 return candidate;
424 }
425 n += 1;
426 }
427 }
428
429 fn gather_call_args(
432 &self,
433 tokens: &[String],
434 positional: &[&str],
435 ) -> HashMap<String, Option<String>> {
436 let mut out = HashMap::new();
437 let mut pos_idx = 0usize;
438 let mut k = 0usize;
439 while k < tokens.len() {
440 let tok = &tokens[k];
441 k += 1;
442 if let Some(splat) = tok.strip_prefix('@') {
443 if let Some(table) = self.tables.get(&splat.to_ascii_lowercase()) {
444 for (key, value) in table {
445 out.insert(key.clone(), value.clone());
446 }
447 }
448 continue;
449 }
450 if let Some(param) = tok.strip_prefix('-') {
451 let value = if k < tokens.len() && !tokens[k].starts_with('-') {
452 let v = self.resolve_rhs(&tokens[k]);
453 k += 1;
454 v
455 } else {
456 Some("true".to_string()) };
458 out.insert(normalize_key(param), value);
459 continue;
460 }
461 if pos_idx < positional.len() {
462 out.insert(positional[pos_idx].to_string(), self.resolve_rhs(tok));
463 pos_idx += 1;
464 }
465 }
466 out
467 }
468
469 fn resolve_rhs(&self, rhs: &str) -> Option<String> {
475 let rhs = rhs.trim();
476 if let Some(v) = full_single_quoted(rhs) {
477 return Some(v);
478 }
479 if let Some(body) = full_double_quoted(rhs) {
480 return self.interp_dquote(body);
481 }
482 if let Some(v) = self.split_path_value(rhs) {
483 return Some(v);
484 }
485 let lower = rhs.to_ascii_lowercase();
486 if lower.starts_with("join-path ") {
487 let toks = tokenize_ps(&rhs["join-path ".len()..]);
488 if let [a, b] = toks.as_slice() {
489 let a = self.resolve_rhs(a)?;
490 let b = self.resolve_rhs(b)?;
491 return Some(format!("{}/{b}", a.trim_end_matches(['/', '\\'])));
492 }
493 return None;
494 }
495 if let Some(inner) = rhs.strip_prefix("$(").and_then(|r| r.strip_suffix(')')) {
496 return self.resolve_subexpr(inner);
497 }
498 if let Some(name) = rhs.strip_prefix('$') {
499 if is_ident(name) {
500 return self.lookup(name);
501 }
502 return None;
503 }
504 if !rhs.is_empty()
507 && !rhs.contains(char::is_whitespace)
508 && !rhs.contains('-')
509 && !rhs.starts_with('@')
510 && !rhs.starts_with('(')
511 {
512 return Some(rhs.to_string());
513 }
514 None
515 }
516
517 fn interp_dquote(&self, body: &str) -> Option<String> {
520 let mut out = String::new();
521 let bytes = body.as_bytes();
522 let mut i = 0usize;
523 while i < body.len() {
524 let c = body[i..].chars().next()?;
525 if c == '`' {
526 i += 1;
527 if let Some(next) = body[i..].chars().next() {
528 out.push(next);
529 i += next.len_utf8();
530 }
531 continue;
532 }
533 if c != '$' {
534 out.push(c);
535 i += c.len_utf8();
536 continue;
537 }
538 i += 1;
539 if bytes.get(i) == Some(&b'(') {
540 let close = find_matching_paren(&body[i..])?;
541 out.push_str(&self.resolve_subexpr(&body[i + 1..i + close])?);
542 i += close + 1;
543 } else if bytes.get(i) == Some(&b'{') {
544 let end = body[i..].find('}')? + i;
545 out.push_str(&self.lookup(&body[i + 1..end])?);
546 i = end + 1;
547 } else {
548 let start = i;
549 while i < body.len() && is_ident_byte(bytes[i]) {
550 i += 1;
551 }
552 if i == start {
553 out.push('$'); continue;
555 }
556 if bytes.get(i) == Some(&b':') {
557 return None; }
559 out.push_str(&self.lookup(&body[start..i])?);
560 }
561 }
562 Some(out)
563 }
564
565 fn resolve_subexpr(&self, inner: &str) -> Option<String> {
568 let inner = inner.trim();
569 if let Some(v) = self.split_path_value(inner) {
570 return Some(v);
571 }
572 inner
573 .strip_prefix('$')
574 .filter(|name| is_ident(name))
575 .and_then(|name| self.lookup(name))
576 }
577
578 fn split_path_value(&self, expr: &str) -> Option<String> {
581 let lower = expr.to_ascii_lowercase();
582 (lower.starts_with("split-path") && lower.contains("$myinvocation.mycommand.definition"))
583 .then(|| self.prefix.clone())
584 }
585
586 fn lookup(&self, name: &str) -> Option<String> {
588 self.vars.get(&name.to_ascii_lowercase()).cloned()
589 }
590
591 fn relativize(&self, path: &str) -> String {
593 path.strip_prefix(&self.prefix).map_or_else(
594 || path.to_string(),
595 |rest| {
596 let rest = rest.trim_start_matches(['/', '\\']);
597 if rest.is_empty() {
598 ".".to_string()
599 } else {
600 rest.to_string()
601 }
602 },
603 )
604 }
605
606 fn finish(self) -> InstallPlan {
608 InstallPlan {
609 steps: self.steps,
610 resources: self.resources,
611 patches: Vec::new(),
612 env: HashMap::new(),
613 deparallelize: false,
614 }
615 }
616}
617
618fn pick_download(args: &HashMap<String, Option<String>>) -> Option<Download> {
626 let get = |key: &str| {
627 args.get(key)
628 .and_then(Clone::clone)
629 .filter(|s| !s.is_empty())
630 };
631 let (url, checksum, ctype) = if let Some(url64) = get("url64bit") {
632 let ctype = get("checksumtype64").or_else(|| get("checksumtype"));
633 (url64, get("checksum64"), ctype)
634 } else {
635 (get("url")?, get("checksum"), get("checksumtype"))
636 };
637 let (sha256, weak) = classify_checksum(checksum, ctype, &url);
638 Some(Download { url, sha256, weak })
639}
640
641fn classify_checksum(
644 checksum: Option<String>,
645 ctype: Option<String>,
646 url: &str,
647) -> (String, Option<String>) {
648 let Some(checksum) = checksum else {
649 return (String::new(), Some(format!("no checksum for {url}")));
650 };
651 match ctype.map(|t| t.to_ascii_lowercase()) {
652 Some(t) if t == "sha256" => (checksum, None),
653 Some(t) => (String::new(), Some(format!("checksumType {t}"))),
654 None if is_sha256_hex(&checksum) => (checksum, None),
656 None => (
657 String::new(),
658 Some(format!("checksumType unknown for {url}")),
659 ),
660 }
661}
662
663fn is_sha256_hex(s: &str) -> bool {
665 s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit())
666}
667
668fn is_ident_byte(b: u8) -> bool {
674 b.is_ascii_alphanumeric() || b == b'_'
675}
676
677fn is_ident(s: &str) -> bool {
679 !s.is_empty() && s.bytes().all(is_ident_byte)
680}
681
682fn strip_ps_comment(raw: &str) -> String {
685 let mut out = String::new();
686 let mut in_single = false;
687 let mut in_double = false;
688 let mut escaped = false;
689 for c in raw.chars() {
690 if escaped {
691 out.push(c);
692 escaped = false;
693 continue;
694 }
695 match c {
696 '`' if in_double => {
697 out.push(c);
698 escaped = true;
699 }
700 '\'' if !in_double => {
701 in_single = !in_single;
702 out.push(c);
703 }
704 '"' if !in_single => {
705 in_double = !in_double;
706 out.push(c);
707 }
708 '#' if !in_single && !in_double => break,
709 _ => out.push(c),
710 }
711 }
712 out
713}
714
715fn tokenize_ps(line: &str) -> Vec<String> {
718 let mut out = Vec::new();
719 let mut cur = String::new();
720 let mut in_single = false;
721 let mut in_double = false;
722 let mut escaped = false;
723 for c in line.chars() {
724 if escaped {
725 cur.push(c);
726 escaped = false;
727 continue;
728 }
729 match c {
730 '`' if in_double => {
731 cur.push(c);
732 escaped = true;
733 }
734 '\'' if !in_double => {
735 in_single = !in_single;
736 cur.push(c);
737 }
738 '"' if !in_single => {
739 in_double = !in_double;
740 cur.push(c);
741 }
742 c if c.is_whitespace() && !in_single && !in_double => {
743 if !cur.is_empty() {
744 out.push(std::mem::take(&mut cur));
745 }
746 }
747 _ => cur.push(c),
748 }
749 }
750 if !cur.is_empty() {
751 out.push(cur);
752 }
753 out
754}
755
756fn line_brace_delta(line: &str) -> i32 {
758 let mut delta = 0i32;
759 let mut in_single = false;
760 let mut in_double = false;
761 let mut escaped = false;
762 for c in line.chars() {
763 if escaped {
764 escaped = false;
765 continue;
766 }
767 match c {
768 '`' if in_double => escaped = true,
769 '\'' if !in_double => in_single = !in_single,
770 '"' if !in_single => in_double = !in_double,
771 '{' if !in_single && !in_double => delta += 1,
772 '}' if !in_single && !in_double => delta -= 1,
773 _ => {}
774 }
775 }
776 delta
777}
778
779fn parse_assignment(line: &str) -> Option<(String, &str)> {
781 let rest = line.strip_prefix('$')?;
782 let end = rest
783 .find(|c: char| !c.is_ascii_alphanumeric() && c != '_')
784 .unwrap_or(rest.len());
785 if end == 0 {
786 return None;
787 }
788 let name = &rest[..end];
789 let rhs = rest[end..].trim_start().strip_prefix('=')?;
790 if rhs.starts_with('=') {
791 return None; }
793 Some((name.to_ascii_lowercase(), rhs.trim()))
794}
795
796fn is_inert(line: &str) -> bool {
798 let lower = line.to_ascii_lowercase();
799 if lower.starts_with("param(") || lower == "param" {
800 return true;
801 }
802 let first = lower.split_whitespace().next().unwrap_or("");
803 matches!(
804 first,
805 "write-host" | "write-output" | "write-verbose" | "write-warning" | "write-debug"
806 )
807}
808
809fn collect_hashtable_body(after_brace: &str, lines: &[&str], i: &mut usize) -> Option<String> {
812 let mut depth = 1i32;
813 let mut body = String::new();
814 let mut chunk = after_brace.to_string();
815 loop {
816 if let Some(pos) = scan_braces(&chunk, &mut depth) {
817 body.push_str(&chunk[..pos]);
818 return Some(body);
819 }
820 body.push_str(&chunk);
821 body.push('\n');
822 if *i >= lines.len() {
823 return None;
824 }
825 chunk = strip_ps_comment(lines[*i].trim());
826 *i += 1;
827 }
828}
829
830fn scan_braces(chunk: &str, depth: &mut i32) -> Option<usize> {
833 let mut in_single = false;
834 let mut in_double = false;
835 let mut escaped = false;
836 for (pos, c) in chunk.char_indices() {
837 if escaped {
838 escaped = false;
839 continue;
840 }
841 match c {
842 '`' if in_double => escaped = true,
843 '\'' if !in_double => in_single = !in_single,
844 '"' if !in_single => in_double = !in_double,
845 '{' if !in_single && !in_double => *depth += 1,
846 '}' if !in_single && !in_double => {
847 *depth -= 1;
848 if *depth == 0 {
849 return Some(pos);
850 }
851 }
852 _ => {}
853 }
854 }
855 None
856}
857
858fn split_semis(s: &str) -> Vec<&str> {
860 let mut out = Vec::new();
861 let mut in_single = false;
862 let mut in_double = false;
863 let mut escaped = false;
864 let mut start = 0usize;
865 for (pos, c) in s.char_indices() {
866 if escaped {
867 escaped = false;
868 continue;
869 }
870 match c {
871 '`' if in_double => escaped = true,
872 '\'' if !in_double => in_single = !in_single,
873 '"' if !in_single => in_double = !in_double,
874 ';' if !in_single && !in_double => {
875 out.push(&s[start..pos]);
876 start = pos + 1;
877 }
878 _ => {}
879 }
880 }
881 out.push(&s[start..]);
882 out
883}
884
885fn split_kv(entry: &str) -> Option<(&str, &str)> {
888 let mut in_single = false;
889 let mut in_double = false;
890 let mut escaped = false;
891 for (pos, c) in entry.char_indices() {
892 if escaped {
893 escaped = false;
894 continue;
895 }
896 match c {
897 '`' if in_double => escaped = true,
898 '\'' if !in_double => in_single = !in_single,
899 '"' if !in_single => in_double = !in_double,
900 '=' if !in_single && !in_double => {
901 let key = entry[..pos].trim();
902 let value = entry[pos + 1..].trim();
903 if is_ident(key) && !value.starts_with('=') {
904 return Some((key, value));
905 }
906 return None;
907 }
908 _ => {}
909 }
910 }
911 None
912}
913
914fn normalize_key(key: &str) -> String {
917 let key = key.to_ascii_lowercase();
918 match key.as_str() {
919 "url64" => "url64bit".to_string(),
920 "destination" => "unziplocation".to_string(),
921 _ => key,
922 }
923}
924
925fn find_matching_paren(s: &str) -> Option<usize> {
927 let mut depth = 0i32;
928 let mut in_single = false;
929 let mut in_double = false;
930 for (pos, c) in s.char_indices() {
931 match c {
932 '\'' if !in_double => in_single = !in_single,
933 '"' if !in_single => in_double = !in_double,
934 '(' if !in_single && !in_double => depth += 1,
935 ')' if !in_single && !in_double => {
936 depth -= 1;
937 if depth == 0 {
938 return Some(pos);
939 }
940 }
941 _ => {}
942 }
943 }
944 None
945}
946
947fn full_single_quoted(s: &str) -> Option<String> {
949 let body = s.strip_prefix('\'')?;
950 let mut out = String::new();
951 let mut chars = body.char_indices().peekable();
952 while let Some((pos, c)) = chars.next() {
953 if c == '\'' {
954 if chars.peek().is_some_and(|&(_, n)| n == '\'') {
955 out.push('\'');
956 chars.next();
957 } else {
958 return (pos == body.len() - 1).then_some(out);
959 }
960 } else {
961 out.push(c);
962 }
963 }
964 None
965}
966
967fn full_double_quoted(s: &str) -> Option<&str> {
970 let body = s.strip_prefix('"')?;
971 let mut escaped = false;
972 for (pos, c) in body.char_indices() {
973 if escaped {
974 escaped = false;
975 continue;
976 }
977 match c {
978 '`' => escaped = true,
979 '"' => return (pos == body.len() - 1).then(|| &body[..pos]),
980 _ => {}
981 }
982 }
983 None
984}
985
986#[cfg(test)]
987mod tests {
988 use super::*;
989
990 fn nuspec_xml(id: &str, version: &str) -> String {
992 format!(
993 r#"<?xml version="1.0" encoding="utf-8"?>
994<package xmlns="http://schemas.microsoft.com/packaging/2011/08/nuspec.xsd">
995 <metadata>
996 <id>{id}</id>
997 <version>{version}</version>
998 <title>{id}</title>
999 <authors>test</authors>
1000 </metadata>
1001</package>"#
1002 )
1003 }
1004
1005 fn build_nupkg(files: &[(&str, &str)]) -> Vec<u8> {
1007 use std::io::Write;
1008 let mut buf = Vec::new();
1009 {
1010 let mut zw = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
1011 let opts = zip::write::SimpleFileOptions::default();
1012 for (name, content) in files {
1013 zw.start_file(*name, opts).unwrap();
1014 zw.write_all(content.as_bytes()).unwrap();
1015 }
1016 zw.finish().unwrap();
1017 }
1018 buf
1019 }
1020
1021 fn recipe_for(id: &str, version: &str, ps1: &str) -> ChocoRecipe {
1024 let nuspec = nuspec_xml(id, version);
1025 let bytes = build_nupkg(&[
1026 (&format!("{id}.nuspec"), nuspec.as_str()),
1027 ("tools/chocolateyInstall.ps1", ps1),
1028 ]);
1029 parse_nupkg(&bytes, Path::new(&format!("/tc/{id}"))).expect("nupkg should parse")
1030 }
1031
1032 #[test]
1039 fn splat_zip_package_terraform_style() {
1040 let ps1 = r"$ErrorActionPreference = 'Stop'
1041
1042# DO NOT CHANGE THESE MANUALLY. USE update.ps1
1043$url = 'https://releases.hashicorp.com/terraform/1.16.0/terraform_1.16.0_windows_386.zip'
1044$url64 = 'https://releases.hashicorp.com/terraform/1.16.0/terraform_1.16.0_windows_amd64.zip'
1045$checksum = 'bbb9fe7d4b6d6f7f9160bd729d190f98bd3c8fa4845793313d72d521d768bc04'
1046$checksum64 = '4f36ac8f3ca338f9bec28e1fc48ce5c3de6e6bd4f11e6cfb25d3bbf65a0ef87a'
1047
1048$unzipLocation = Split-Path -Parent $MyInvocation.MyCommand.Definition
1049
1050$packageParams = @{
1051 PackageName = 'terraform'
1052 UnzipLocation = $unzipLocation
1053 Url = $url
1054 Url64 = $url64
1055 Checksum = $checksum
1056 Checksum64 = $checksum64
1057 ChecksumType = 'sha256'
1058}
1059
1060Install-ChocolateyZipPackage @packageParams
1061";
1062 let r = recipe_for("terraform", "1.16.0", ps1);
1063 assert_eq!(r.id, "terraform");
1064 assert_eq!(r.version, "1.16.0");
1065 assert!(
1066 r.plan.is_fully_supported(),
1067 "{:?}",
1068 r.plan.unsupported_constructs()
1069 );
1070 assert_eq!(
1071 r.plan.resources,
1072 vec![ResourceSpec {
1073 name: "terraform".into(),
1074 url: "https://releases.hashicorp.com/terraform/1.16.0/terraform_1.16.0_windows_amd64.zip".into(),
1075 sha256: "4f36ac8f3ca338f9bec28e1fc48ce5c3de6e6bd4f11e6cfb25d3bbf65a0ef87a".into(),
1076 stage_to: None,
1077 }]
1078 );
1079 assert_eq!(
1080 r.plan.steps,
1081 vec![
1082 InstallStep::StageResource {
1083 name: "terraform".into(),
1084 dir: None
1085 },
1086 InstallStep::PrefixInstall {
1087 from: ".".into(),
1088 to: None
1089 },
1090 ]
1091 );
1092 }
1093
1094 #[test]
1098 fn get_chocolatey_web_file_jq_verbatim() {
1099 let ps1 = r#"$ErrorActionPreference = 'Stop'
1100$toolsDir = "$(Split-Path -parent $MyInvocation.MyCommand.Definition)"
1101
1102$fileFullPath = Join-Path $toolsDir "$packageName.exe"
1103$url = 'https://github.com/jqlang/jq/releases/download/jq-1.8.1/jq-windows-i386.exe'
1104$checksumType = "sha256"
1105$checksum = '414ec99417830178bd2f6e77fc78b34de3b12fc6b6c3229f07038c5811307124'
1106$url64 = 'https://github.com/jqlang/jq/releases/download/jq-1.8.1/jq-windows-amd64.exe'
1107$checksumType64 = "sha256"
1108$checksum64 = '23cb60a1354eed6bcc8d9b9735e8c7b388cd1fdcb75726b93bc299ef22dd9334'
1109
1110$packageArgs = @{
1111 PackageName = $env:ChocolateyPackageName
1112 FileFullPath = $fileFullPath
1113 Url = $url
1114 ChecksumType = $checksumType
1115 Checksum = $checksum
1116 Url64bit = $url64
1117 ChecksumType64 = $checksumType64
1118 Checksum64 = $checksum64
1119}
1120
1121Get-ChocolateyWebFile @packageArgs
1122"#;
1123 let r = recipe_for("jq", "1.8.1", ps1);
1124 assert!(
1125 r.plan.is_fully_supported(),
1126 "{:?}",
1127 r.plan.unsupported_constructs()
1128 );
1129 assert_eq!(
1130 r.plan.resources,
1131 vec![ResourceSpec {
1132 name: "jq".into(),
1133 url: "https://github.com/jqlang/jq/releases/download/jq-1.8.1/jq-windows-amd64.exe"
1134 .into(),
1135 sha256: "23cb60a1354eed6bcc8d9b9735e8c7b388cd1fdcb75726b93bc299ef22dd9334".into(),
1136 stage_to: None,
1137 }]
1138 );
1139 assert_eq!(
1141 r.plan.steps,
1142 vec![InstallStep::StageResource {
1143 name: "jq".into(),
1144 dir: None
1145 }]
1146 );
1147 }
1148
1149 #[test]
1152 fn positional_zip_package() {
1153 let ps1 = r#"$toolsDir = "$(Split-Path -Parent $MyInvocation.MyCommand.Definition)"
1154Install-ChocolateyZipPackage 'sample' 'https://example.com/sample-1.0.0.zip' $toolsDir -Checksum '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' -ChecksumType 'sha256'
1155"#;
1156 let r = recipe_for("sample", "1.0.0", ps1);
1157 assert!(
1158 r.plan.is_fully_supported(),
1159 "{:?}",
1160 r.plan.unsupported_constructs()
1161 );
1162 assert_eq!(
1163 r.plan.resources,
1164 vec![ResourceSpec {
1165 name: "sample".into(),
1166 url: "https://example.com/sample-1.0.0.zip".into(),
1167 sha256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".into(),
1168 stage_to: None,
1169 }]
1170 );
1171 assert_eq!(
1172 r.plan.steps,
1173 vec![
1174 InstallStep::StageResource {
1175 name: "sample".into(),
1176 dir: None
1177 },
1178 InstallStep::PrefixInstall {
1179 from: ".".into(),
1180 to: None
1181 },
1182 ]
1183 );
1184 }
1185
1186 #[test]
1189 fn install_chocolatey_package_exe_is_unsupported() {
1190 let ps1 = r"$packageArgs = @{
1191 packageName = 'sample'
1192 fileType = 'exe'
1193 url = 'https://example.com/sample-setup.exe'
1194 silentArgs = '/S'
1195 validExitCodes = @(0)
1196}
1197Install-ChocolateyPackage @packageArgs
1198";
1199 let r = recipe_for("sample", "1.0.0", ps1);
1200 assert!(!r.plan.is_fully_supported());
1201 assert!(r.plan.resources.is_empty());
1202 let constructs = r.plan.unsupported_constructs();
1203 assert_eq!(constructs.len(), 1);
1204 assert!(constructs[0].contains("Install-ChocolateyPackage"));
1205 assert!(constructs[0].contains("https://example.com/sample-setup.exe"));
1206 }
1207
1208 #[test]
1211 fn md5_checksum_type_weakens_verification() {
1212 let ps1 = r#"Install-ChocolateyZipPackage -PackageName 'sample' -Url 'https://example.com/s.zip' -Checksum 'd41d8cd98f00b204e9800998ecf8427e' -ChecksumType 'md5' -UnzipLocation "$(Split-Path -Parent $MyInvocation.MyCommand.Definition)"
1213"#;
1214 let r = recipe_for("sample", "1.0.0", ps1);
1215 assert!(!r.plan.is_fully_supported());
1216 assert_eq!(
1217 r.plan.resources,
1218 vec![ResourceSpec {
1219 name: "sample".into(),
1220 url: "https://example.com/s.zip".into(),
1221 sha256: String::new(),
1222 stage_to: None,
1223 }]
1224 );
1225 assert_eq!(
1227 r.plan.steps,
1228 vec![
1229 InstallStep::StageResource {
1230 name: "sample".into(),
1231 dir: None
1232 },
1233 InstallStep::PrefixInstall {
1234 from: ".".into(),
1235 to: None
1236 },
1237 InstallStep::Unsupported {
1238 construct: "checksumType md5".into()
1239 },
1240 ]
1241 );
1242 }
1243
1244 #[test]
1246 fn malformed_packages_error_cleanly() {
1247 let prefix = Path::new("/tc/sample");
1248
1249 let err = parse_nupkg(b"definitely not a zip archive", prefix).unwrap_err();
1250 assert!(matches!(err, ToolchainError::RegistryError { .. }), "{err}");
1251
1252 let no_nuspec = build_nupkg(&[("tools/chocolateyInstall.ps1", "Write-Host hi")]);
1253 let err = parse_nupkg(&no_nuspec, prefix).unwrap_err();
1254 assert!(
1255 matches!(&err, ToolchainError::RegistryError { message } if message.contains("nuspec")),
1256 "{err}"
1257 );
1258
1259 let nuspec = nuspec_xml("sample", "1.0.0");
1260 let no_ps1 = build_nupkg(&[("sample.nuspec", nuspec.as_str())]);
1261 let err = parse_nupkg(&no_ps1, prefix).unwrap_err();
1262 assert!(
1263 matches!(&err, ToolchainError::RegistryError { message } if message.contains("chocolateyInstall")),
1264 "{err}"
1265 );
1266 }
1267
1268 #[test]
1271 fn install_bin_file_maps_to_bin_install() {
1272 let ps1 = r#"$toolsDir = "$(Split-Path -Parent $MyInvocation.MyCommand.Definition)"
1273Install-BinFile -Name 'fooctl' -Path "$toolsDir\fooctl.exe"
1274"#;
1275 let r = recipe_for("sample", "1.0.0", ps1);
1276 assert!(
1277 r.plan.is_fully_supported(),
1278 "{:?}",
1279 r.plan.unsupported_constructs()
1280 );
1281 assert_eq!(
1282 r.plan.steps,
1283 vec![InstallStep::BinInstall {
1284 from: "fooctl.exe".into(),
1285 to: Some("fooctl".into()),
1286 }]
1287 );
1288 }
1289
1290 #[test]
1294 fn opaque_constructs_surface_as_unsupported() {
1295 let ps1 = r#"$pp = Get-PackageParameters
1296if (Test-Path "C:\old") {
1297 Remove-Item "C:\old" -Recurse -Force
1298}
1299Start-ChocolateyProcessAsAdmin 'setup.exe'
1300"#;
1301 let r = recipe_for("sample", "1.0.0", ps1);
1302 assert!(!r.plan.is_fully_supported());
1303 let constructs = r.plan.unsupported_constructs();
1304 assert_eq!(constructs.len(), 3, "{constructs:?}");
1305 assert!(constructs[0].contains("Get-PackageParameters"));
1306 assert!(constructs[1].starts_with("if "));
1307 assert!(constructs[2].contains("Start-ChocolateyProcessAsAdmin"));
1308 }
1309}