use std::collections::HashMap;
use std::io::Read;
use std::path::Path;
use crate::error::{Result, ToolchainError};
use crate::recipe::{InstallPlan, InstallStep, ResourceSpec};
#[derive(Debug, Clone, PartialEq)]
pub struct ChocoRecipe {
pub id: String,
pub version: String,
pub plan: InstallPlan,
}
pub fn parse_nupkg(bytes: &[u8], prefix: &Path) -> Result<ChocoRecipe> {
let cursor = std::io::Cursor::new(bytes);
let mut archive = zip::ZipArchive::new(cursor).map_err(|e| ToolchainError::RegistryError {
message: format!("failed to open .nupkg as a zip archive: {e}"),
})?;
let names: Vec<String> = archive.file_names().map(str::to_string).collect();
let nuspec_name = names
.iter()
.find(|n| is_root_nuspec(n))
.cloned()
.ok_or_else(|| ToolchainError::RegistryError {
message: "no *.nuspec found at the .nupkg archive root".to_string(),
})?;
let nuspec = read_zip_text(&mut archive, &nuspec_name)?;
let (id, version) = parse_nuspec(&nuspec)?;
let ps1_name = names
.iter()
.find(|n| is_install_ps1(n))
.cloned()
.ok_or_else(|| ToolchainError::RegistryError {
message: format!("no tools/chocolateyInstall.ps1 in .nupkg for {id}"),
})?;
let ps1 = read_zip_text(&mut archive, &ps1_name)?;
let plan = plan_from_ps1(&ps1, &id, &version, prefix);
Ok(ChocoRecipe { id, version, plan })
}
fn is_root_nuspec(name: &str) -> bool {
!name.contains('/')
&& !name.contains('\\')
&& Path::new(&name.to_ascii_lowercase())
.extension()
.is_some_and(|e| e == "nuspec")
}
fn is_install_ps1(name: &str) -> bool {
name.to_ascii_lowercase().replace('\\', "/") == "tools/chocolateyinstall.ps1"
}
fn read_zip_text(
archive: &mut zip::ZipArchive<std::io::Cursor<&[u8]>>,
name: &str,
) -> Result<String> {
let mut file = archive
.by_name(name)
.map_err(|e| ToolchainError::RegistryError {
message: format!("failed to read `{name}` from .nupkg: {e}"),
})?;
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)?;
let text = String::from_utf8_lossy(&bytes).into_owned();
Ok(text.trim_start_matches('\u{feff}').to_string())
}
fn parse_nuspec(xml: &str) -> Result<(String, String)> {
let meta = xml_region(xml, "metadata").unwrap_or(xml);
let id = xml_region(meta, "id")
.map(str::trim)
.filter(|s| !s.is_empty());
let version = xml_region(meta, "version")
.map(str::trim)
.filter(|s| !s.is_empty());
match (id, version) {
(Some(id), Some(version)) => Ok((id.to_string(), version.to_string())),
_ => Err(ToolchainError::RegistryError {
message: "nuspec is missing <id> or <version> inside <metadata>".to_string(),
}),
}
}
fn xml_region<'a>(xml: &'a str, tag: &str) -> Option<&'a str> {
let lower = xml.to_ascii_lowercase();
let open = format!("<{tag}");
let close = format!("</{tag}");
let mut search = 0;
while let Some(rel) = lower[search..].find(&open) {
let after = search + rel + open.len();
if !matches!(
lower.as_bytes().get(after),
Some(b'>' | b' ' | b'\t' | b'\r' | b'\n')
) {
search = after;
continue;
}
let gt = lower[after..].find('>')? + after;
if lower.as_bytes()[gt - 1] == b'/' {
search = gt + 1;
continue; }
let end = lower[gt + 1..].find(&close)? + gt + 1;
return Some(&xml[gt + 1..end]);
}
None
}
struct Download {
url: String,
sha256: String,
weak: Option<String>,
}
struct PsPlanner {
pkg_id: String,
prefix: String,
vars: HashMap<String, String>,
tables: HashMap<String, HashMap<String, Option<String>>>,
steps: Vec<InstallStep>,
resources: Vec<ResourceSpec>,
}
fn plan_from_ps1(ps1: &str, id: &str, version: &str, prefix: &Path) -> InstallPlan {
let mut planner = PsPlanner::new(id, version, prefix);
let lines: Vec<&str> = ps1.lines().collect();
let mut i = 0usize;
while i < lines.len() {
let raw = lines[i].trim_start_matches('\u{feff}').trim();
i += 1;
if raw.starts_with("<#") {
let mut cur = raw;
while !cur.contains("#>") && i < lines.len() {
cur = lines[i];
i += 1;
}
continue;
}
let line = strip_ps_comment(raw);
let line = line.trim();
if line.is_empty() {
continue;
}
planner.dispatch_line(line, &lines, &mut i);
}
planner.finish()
}
impl PsPlanner {
fn new(id: &str, version: &str, prefix: &Path) -> Self {
let prefix = prefix.display().to_string();
let mut vars = HashMap::new();
vars.insert("packagename".to_string(), id.to_string());
vars.insert("packageversion".to_string(), version.to_string());
vars.insert("packagefolder".to_string(), prefix.clone());
Self {
pkg_id: id.to_string(),
prefix,
vars,
tables: HashMap::new(),
steps: Vec::new(),
resources: Vec::new(),
}
}
fn unsupported(&mut self, construct: &str) {
self.steps.push(InstallStep::Unsupported {
construct: construct.to_string(),
});
}
fn unsupported_block(&mut self, line: &str, lines: &[&str], i: &mut usize) {
self.unsupported(line);
let mut delta = line_brace_delta(line);
while delta > 0 && *i < lines.len() {
let next = strip_ps_comment(lines[*i].trim());
*i += 1;
delta += line_brace_delta(&next);
}
}
fn dispatch_line(&mut self, line: &str, lines: &[&str], i: &mut usize) {
if let Some((name, rhs)) = parse_assignment(line) {
self.assignment(line, &name, rhs, lines, i);
return;
}
if is_inert(line) {
return;
}
let tokens = tokenize_ps(line);
let Some(cmd) = tokens.first().cloned() else {
return;
};
match cmd.to_ascii_lowercase().as_str() {
"install-chocolateyzippackage" => self.cmd_zip_package(&tokens[1..], line),
"get-chocolateywebfile" => self.cmd_web_file(&tokens[1..], line),
"install-chocolateypackage" | "install-chocolateyinstallpackage" => {
self.cmd_loud_installer(&cmd, &tokens[1..], line);
}
"install-binfile" => self.cmd_bin_file(&tokens[1..], line),
_ => self.unsupported_block(line, lines, i),
}
}
fn assignment(&mut self, line: &str, name: &str, rhs: &str, lines: &[&str], i: &mut usize) {
if name == "erroractionpreference" {
return; }
if let Some(after_brace) = rhs.strip_prefix("@{") {
match collect_hashtable_body(after_brace, lines, i) {
Some(body) => {
let (map, junk) = self.parse_table(&body);
for entry in junk {
self.unsupported(&entry);
}
self.tables.insert(name.to_string(), map);
}
None => self.unsupported(line),
}
return;
}
match self.resolve_rhs(rhs) {
Some(value) => {
self.vars.insert(name.to_string(), value);
}
None => self.unsupported_block(line, lines, i),
}
}
fn parse_table(&self, body: &str) -> (HashMap<String, Option<String>>, Vec<String>) {
let mut map = HashMap::new();
let mut junk = Vec::new();
for line in body.lines() {
for entry in split_semis(line) {
let entry = entry.trim();
if entry.is_empty() {
continue;
}
match split_kv(entry) {
Some((key, value)) => {
map.insert(normalize_key(key), self.resolve_rhs(value));
}
None => junk.push(entry.to_string()),
}
}
}
(map, junk)
}
fn cmd_zip_package(&mut self, args: &[String], line: &str) {
let args =
self.gather_call_args(args, &["packagename", "url", "unziplocation", "url64bit"]);
match pick_download(&args) {
Some(dl) => self.push_download(dl, true),
None => self.unsupported(line),
}
}
fn cmd_web_file(&mut self, args: &[String], line: &str) {
let args = self.gather_call_args(args, &["packagename", "filefullpath", "url", "url64bit"]);
match pick_download(&args) {
Some(dl) => self.push_download(dl, false),
None => self.unsupported(line),
}
}
fn cmd_loud_installer(&mut self, cmd: &str, args: &[String], line: &str) {
let args = self.gather_call_args(args, &["packagename", "filetype", "silentargs", "url"]);
let url = args
.get("url64bit")
.or_else(|| args.get("url"))
.and_then(Clone::clone);
let construct = url.map_or_else(|| line.to_string(), |u| format!("{cmd} {u}"));
self.unsupported(&construct);
}
fn cmd_bin_file(&mut self, args: &[String], line: &str) {
let args = self.gather_call_args(args, &["name", "path"]);
let name = args.get("name").and_then(Clone::clone);
let path = args.get("path").and_then(Clone::clone);
match (name, path) {
(Some(name), Some(path)) => {
let from = self.relativize(&path);
self.steps.push(InstallStep::BinInstall {
from,
to: Some(name),
});
}
_ => self.unsupported(line),
}
}
fn push_download(&mut self, dl: Download, extract_to_prefix: bool) {
let name = self.unique_resource_name();
self.resources.push(ResourceSpec {
name: name.clone(),
url: dl.url,
sha256: dl.sha256,
stage_to: None,
});
self.steps
.push(InstallStep::StageResource { name, dir: None });
if extract_to_prefix {
self.steps.push(InstallStep::PrefixInstall {
from: ".".to_string(),
to: None,
});
}
if let Some(weak) = dl.weak {
self.unsupported(&weak);
}
}
fn unique_resource_name(&self) -> String {
let taken = |candidate: &str| self.resources.iter().any(|r| r.name == candidate);
if !taken(&self.pkg_id) {
return self.pkg_id.clone();
}
let mut n = 2usize;
loop {
let candidate = format!("{}-{n}", self.pkg_id);
if !taken(&candidate) {
return candidate;
}
n += 1;
}
}
fn gather_call_args(
&self,
tokens: &[String],
positional: &[&str],
) -> HashMap<String, Option<String>> {
let mut out = HashMap::new();
let mut pos_idx = 0usize;
let mut k = 0usize;
while k < tokens.len() {
let tok = &tokens[k];
k += 1;
if let Some(splat) = tok.strip_prefix('@') {
if let Some(table) = self.tables.get(&splat.to_ascii_lowercase()) {
for (key, value) in table {
out.insert(key.clone(), value.clone());
}
}
continue;
}
if let Some(param) = tok.strip_prefix('-') {
let value = if k < tokens.len() && !tokens[k].starts_with('-') {
let v = self.resolve_rhs(&tokens[k]);
k += 1;
v
} else {
Some("true".to_string()) };
out.insert(normalize_key(param), value);
continue;
}
if pos_idx < positional.len() {
out.insert(positional[pos_idx].to_string(), self.resolve_rhs(tok));
pos_idx += 1;
}
}
out
}
fn resolve_rhs(&self, rhs: &str) -> Option<String> {
let rhs = rhs.trim();
if let Some(v) = full_single_quoted(rhs) {
return Some(v);
}
if let Some(body) = full_double_quoted(rhs) {
return self.interp_dquote(body);
}
if let Some(v) = self.split_path_value(rhs) {
return Some(v);
}
let lower = rhs.to_ascii_lowercase();
if lower.starts_with("join-path ") {
let toks = tokenize_ps(&rhs["join-path ".len()..]);
if let [a, b] = toks.as_slice() {
let a = self.resolve_rhs(a)?;
let b = self.resolve_rhs(b)?;
return Some(format!("{}/{b}", a.trim_end_matches(['/', '\\'])));
}
return None;
}
if let Some(inner) = rhs.strip_prefix("$(").and_then(|r| r.strip_suffix(')')) {
return self.resolve_subexpr(inner);
}
if let Some(name) = rhs.strip_prefix('$') {
if is_ident(name) {
return self.lookup(name);
}
return None;
}
if !rhs.is_empty()
&& !rhs.contains(char::is_whitespace)
&& !rhs.contains('-')
&& !rhs.starts_with('@')
&& !rhs.starts_with('(')
{
return Some(rhs.to_string());
}
None
}
fn interp_dquote(&self, body: &str) -> Option<String> {
let mut out = String::new();
let bytes = body.as_bytes();
let mut i = 0usize;
while i < body.len() {
let c = body[i..].chars().next()?;
if c == '`' {
i += 1;
if let Some(next) = body[i..].chars().next() {
out.push(next);
i += next.len_utf8();
}
continue;
}
if c != '$' {
out.push(c);
i += c.len_utf8();
continue;
}
i += 1;
if bytes.get(i) == Some(&b'(') {
let close = find_matching_paren(&body[i..])?;
out.push_str(&self.resolve_subexpr(&body[i + 1..i + close])?);
i += close + 1;
} else if bytes.get(i) == Some(&b'{') {
let end = body[i..].find('}')? + i;
out.push_str(&self.lookup(&body[i + 1..end])?);
i = end + 1;
} else {
let start = i;
while i < body.len() && is_ident_byte(bytes[i]) {
i += 1;
}
if i == start {
out.push('$'); continue;
}
if bytes.get(i) == Some(&b':') {
return None; }
out.push_str(&self.lookup(&body[start..i])?);
}
}
Some(out)
}
fn resolve_subexpr(&self, inner: &str) -> Option<String> {
let inner = inner.trim();
if let Some(v) = self.split_path_value(inner) {
return Some(v);
}
inner
.strip_prefix('$')
.filter(|name| is_ident(name))
.and_then(|name| self.lookup(name))
}
fn split_path_value(&self, expr: &str) -> Option<String> {
let lower = expr.to_ascii_lowercase();
(lower.starts_with("split-path") && lower.contains("$myinvocation.mycommand.definition"))
.then(|| self.prefix.clone())
}
fn lookup(&self, name: &str) -> Option<String> {
self.vars.get(&name.to_ascii_lowercase()).cloned()
}
fn relativize(&self, path: &str) -> String {
path.strip_prefix(&self.prefix).map_or_else(
|| path.to_string(),
|rest| {
let rest = rest.trim_start_matches(['/', '\\']);
if rest.is_empty() {
".".to_string()
} else {
rest.to_string()
}
},
)
}
fn finish(self) -> InstallPlan {
InstallPlan {
steps: self.steps,
resources: self.resources,
patches: Vec::new(),
env: HashMap::new(),
deparallelize: false,
}
}
}
fn pick_download(args: &HashMap<String, Option<String>>) -> Option<Download> {
let get = |key: &str| {
args.get(key)
.and_then(Clone::clone)
.filter(|s| !s.is_empty())
};
let (url, checksum, ctype) = if let Some(url64) = get("url64bit") {
let ctype = get("checksumtype64").or_else(|| get("checksumtype"));
(url64, get("checksum64"), ctype)
} else {
(get("url")?, get("checksum"), get("checksumtype"))
};
let (sha256, weak) = classify_checksum(checksum, ctype, &url);
Some(Download { url, sha256, weak })
}
fn classify_checksum(
checksum: Option<String>,
ctype: Option<String>,
url: &str,
) -> (String, Option<String>) {
let Some(checksum) = checksum else {
return (String::new(), Some(format!("no checksum for {url}")));
};
match ctype.map(|t| t.to_ascii_lowercase()) {
Some(t) if t == "sha256" => (checksum, None),
Some(t) => (String::new(), Some(format!("checksumType {t}"))),
None if is_sha256_hex(&checksum) => (checksum, None),
None => (
String::new(),
Some(format!("checksumType unknown for {url}")),
),
}
}
fn is_sha256_hex(s: &str) -> bool {
s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit())
}
fn is_ident_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_'
}
fn is_ident(s: &str) -> bool {
!s.is_empty() && s.bytes().all(is_ident_byte)
}
fn strip_ps_comment(raw: &str) -> String {
let mut out = String::new();
let mut in_single = false;
let mut in_double = false;
let mut escaped = false;
for c in raw.chars() {
if escaped {
out.push(c);
escaped = false;
continue;
}
match c {
'`' if in_double => {
out.push(c);
escaped = true;
}
'\'' if !in_double => {
in_single = !in_single;
out.push(c);
}
'"' if !in_single => {
in_double = !in_double;
out.push(c);
}
'#' if !in_single && !in_double => break,
_ => out.push(c),
}
}
out
}
fn tokenize_ps(line: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut in_single = false;
let mut in_double = false;
let mut escaped = false;
for c in line.chars() {
if escaped {
cur.push(c);
escaped = false;
continue;
}
match c {
'`' if in_double => {
cur.push(c);
escaped = true;
}
'\'' if !in_double => {
in_single = !in_single;
cur.push(c);
}
'"' if !in_single => {
in_double = !in_double;
cur.push(c);
}
c if c.is_whitespace() && !in_single && !in_double => {
if !cur.is_empty() {
out.push(std::mem::take(&mut cur));
}
}
_ => cur.push(c),
}
}
if !cur.is_empty() {
out.push(cur);
}
out
}
fn line_brace_delta(line: &str) -> i32 {
let mut delta = 0i32;
let mut in_single = false;
let mut in_double = false;
let mut escaped = false;
for c in line.chars() {
if escaped {
escaped = false;
continue;
}
match c {
'`' if in_double => escaped = true,
'\'' if !in_double => in_single = !in_single,
'"' if !in_single => in_double = !in_double,
'{' if !in_single && !in_double => delta += 1,
'}' if !in_single && !in_double => delta -= 1,
_ => {}
}
}
delta
}
fn parse_assignment(line: &str) -> Option<(String, &str)> {
let rest = line.strip_prefix('$')?;
let end = rest
.find(|c: char| !c.is_ascii_alphanumeric() && c != '_')
.unwrap_or(rest.len());
if end == 0 {
return None;
}
let name = &rest[..end];
let rhs = rest[end..].trim_start().strip_prefix('=')?;
if rhs.starts_with('=') {
return None; }
Some((name.to_ascii_lowercase(), rhs.trim()))
}
fn is_inert(line: &str) -> bool {
let lower = line.to_ascii_lowercase();
if lower.starts_with("param(") || lower == "param" {
return true;
}
let first = lower.split_whitespace().next().unwrap_or("");
matches!(
first,
"write-host" | "write-output" | "write-verbose" | "write-warning" | "write-debug"
)
}
fn collect_hashtable_body(after_brace: &str, lines: &[&str], i: &mut usize) -> Option<String> {
let mut depth = 1i32;
let mut body = String::new();
let mut chunk = after_brace.to_string();
loop {
if let Some(pos) = scan_braces(&chunk, &mut depth) {
body.push_str(&chunk[..pos]);
return Some(body);
}
body.push_str(&chunk);
body.push('\n');
if *i >= lines.len() {
return None;
}
chunk = strip_ps_comment(lines[*i].trim());
*i += 1;
}
}
fn scan_braces(chunk: &str, depth: &mut i32) -> Option<usize> {
let mut in_single = false;
let mut in_double = false;
let mut escaped = false;
for (pos, c) in chunk.char_indices() {
if escaped {
escaped = false;
continue;
}
match c {
'`' if in_double => escaped = true,
'\'' if !in_double => in_single = !in_single,
'"' if !in_single => in_double = !in_double,
'{' if !in_single && !in_double => *depth += 1,
'}' if !in_single && !in_double => {
*depth -= 1;
if *depth == 0 {
return Some(pos);
}
}
_ => {}
}
}
None
}
fn split_semis(s: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut in_single = false;
let mut in_double = false;
let mut escaped = false;
let mut start = 0usize;
for (pos, c) in s.char_indices() {
if escaped {
escaped = false;
continue;
}
match c {
'`' if in_double => escaped = true,
'\'' if !in_double => in_single = !in_single,
'"' if !in_single => in_double = !in_double,
';' if !in_single && !in_double => {
out.push(&s[start..pos]);
start = pos + 1;
}
_ => {}
}
}
out.push(&s[start..]);
out
}
fn split_kv(entry: &str) -> Option<(&str, &str)> {
let mut in_single = false;
let mut in_double = false;
let mut escaped = false;
for (pos, c) in entry.char_indices() {
if escaped {
escaped = false;
continue;
}
match c {
'`' if in_double => escaped = true,
'\'' if !in_double => in_single = !in_single,
'"' if !in_single => in_double = !in_double,
'=' if !in_single && !in_double => {
let key = entry[..pos].trim();
let value = entry[pos + 1..].trim();
if is_ident(key) && !value.starts_with('=') {
return Some((key, value));
}
return None;
}
_ => {}
}
}
None
}
fn normalize_key(key: &str) -> String {
let key = key.to_ascii_lowercase();
match key.as_str() {
"url64" => "url64bit".to_string(),
"destination" => "unziplocation".to_string(),
_ => key,
}
}
fn find_matching_paren(s: &str) -> Option<usize> {
let mut depth = 0i32;
let mut in_single = false;
let mut in_double = false;
for (pos, c) in s.char_indices() {
match c {
'\'' if !in_double => in_single = !in_single,
'"' if !in_single => in_double = !in_double,
'(' if !in_single && !in_double => depth += 1,
')' if !in_single && !in_double => {
depth -= 1;
if depth == 0 {
return Some(pos);
}
}
_ => {}
}
}
None
}
fn full_single_quoted(s: &str) -> Option<String> {
let body = s.strip_prefix('\'')?;
let mut out = String::new();
let mut chars = body.char_indices().peekable();
while let Some((pos, c)) = chars.next() {
if c == '\'' {
if chars.peek().is_some_and(|&(_, n)| n == '\'') {
out.push('\'');
chars.next();
} else {
return (pos == body.len() - 1).then_some(out);
}
} else {
out.push(c);
}
}
None
}
fn full_double_quoted(s: &str) -> Option<&str> {
let body = s.strip_prefix('"')?;
let mut escaped = false;
for (pos, c) in body.char_indices() {
if escaped {
escaped = false;
continue;
}
match c {
'`' => escaped = true,
'"' => return (pos == body.len() - 1).then(|| &body[..pos]),
_ => {}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn nuspec_xml(id: &str, version: &str) -> String {
format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://schemas.microsoft.com/packaging/2011/08/nuspec.xsd">
<metadata>
<id>{id}</id>
<version>{version}</version>
<title>{id}</title>
<authors>test</authors>
</metadata>
</package>"#
)
}
fn build_nupkg(files: &[(&str, &str)]) -> Vec<u8> {
use std::io::Write;
let mut buf = Vec::new();
{
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
let opts = zip::write::SimpleFileOptions::default();
for (name, content) in files {
zw.start_file(*name, opts).unwrap();
zw.write_all(content.as_bytes()).unwrap();
}
zw.finish().unwrap();
}
buf
}
fn recipe_for(id: &str, version: &str, ps1: &str) -> ChocoRecipe {
let nuspec = nuspec_xml(id, version);
let bytes = build_nupkg(&[
(&format!("{id}.nuspec"), nuspec.as_str()),
("tools/chocolateyInstall.ps1", ps1),
]);
parse_nupkg(&bytes, Path::new(&format!("/tc/{id}"))).expect("nupkg should parse")
}
#[test]
fn splat_zip_package_terraform_style() {
let ps1 = r"$ErrorActionPreference = 'Stop'
# DO NOT CHANGE THESE MANUALLY. USE update.ps1
$url = 'https://releases.hashicorp.com/terraform/1.16.0/terraform_1.16.0_windows_386.zip'
$url64 = 'https://releases.hashicorp.com/terraform/1.16.0/terraform_1.16.0_windows_amd64.zip'
$checksum = 'bbb9fe7d4b6d6f7f9160bd729d190f98bd3c8fa4845793313d72d521d768bc04'
$checksum64 = '4f36ac8f3ca338f9bec28e1fc48ce5c3de6e6bd4f11e6cfb25d3bbf65a0ef87a'
$unzipLocation = Split-Path -Parent $MyInvocation.MyCommand.Definition
$packageParams = @{
PackageName = 'terraform'
UnzipLocation = $unzipLocation
Url = $url
Url64 = $url64
Checksum = $checksum
Checksum64 = $checksum64
ChecksumType = 'sha256'
}
Install-ChocolateyZipPackage @packageParams
";
let r = recipe_for("terraform", "1.16.0", ps1);
assert_eq!(r.id, "terraform");
assert_eq!(r.version, "1.16.0");
assert!(
r.plan.is_fully_supported(),
"{:?}",
r.plan.unsupported_constructs()
);
assert_eq!(
r.plan.resources,
vec![ResourceSpec {
name: "terraform".into(),
url: "https://releases.hashicorp.com/terraform/1.16.0/terraform_1.16.0_windows_amd64.zip".into(),
sha256: "4f36ac8f3ca338f9bec28e1fc48ce5c3de6e6bd4f11e6cfb25d3bbf65a0ef87a".into(),
stage_to: None,
}]
);
assert_eq!(
r.plan.steps,
vec![
InstallStep::StageResource {
name: "terraform".into(),
dir: None
},
InstallStep::PrefixInstall {
from: ".".into(),
to: None
},
]
);
}
#[test]
fn get_chocolatey_web_file_jq_verbatim() {
let ps1 = r#"$ErrorActionPreference = 'Stop'
$toolsDir = "$(Split-Path -parent $MyInvocation.MyCommand.Definition)"
$fileFullPath = Join-Path $toolsDir "$packageName.exe"
$url = 'https://github.com/jqlang/jq/releases/download/jq-1.8.1/jq-windows-i386.exe'
$checksumType = "sha256"
$checksum = '414ec99417830178bd2f6e77fc78b34de3b12fc6b6c3229f07038c5811307124'
$url64 = 'https://github.com/jqlang/jq/releases/download/jq-1.8.1/jq-windows-amd64.exe'
$checksumType64 = "sha256"
$checksum64 = '23cb60a1354eed6bcc8d9b9735e8c7b388cd1fdcb75726b93bc299ef22dd9334'
$packageArgs = @{
PackageName = $env:ChocolateyPackageName
FileFullPath = $fileFullPath
Url = $url
ChecksumType = $checksumType
Checksum = $checksum
Url64bit = $url64
ChecksumType64 = $checksumType64
Checksum64 = $checksum64
}
Get-ChocolateyWebFile @packageArgs
"#;
let r = recipe_for("jq", "1.8.1", ps1);
assert!(
r.plan.is_fully_supported(),
"{:?}",
r.plan.unsupported_constructs()
);
assert_eq!(
r.plan.resources,
vec![ResourceSpec {
name: "jq".into(),
url: "https://github.com/jqlang/jq/releases/download/jq-1.8.1/jq-windows-amd64.exe"
.into(),
sha256: "23cb60a1354eed6bcc8d9b9735e8c7b388cd1fdcb75726b93bc299ef22dd9334".into(),
stage_to: None,
}]
);
assert_eq!(
r.plan.steps,
vec![InstallStep::StageResource {
name: "jq".into(),
dir: None
}]
);
}
#[test]
fn positional_zip_package() {
let ps1 = r#"$toolsDir = "$(Split-Path -Parent $MyInvocation.MyCommand.Definition)"
Install-ChocolateyZipPackage 'sample' 'https://example.com/sample-1.0.0.zip' $toolsDir -Checksum '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' -ChecksumType 'sha256'
"#;
let r = recipe_for("sample", "1.0.0", ps1);
assert!(
r.plan.is_fully_supported(),
"{:?}",
r.plan.unsupported_constructs()
);
assert_eq!(
r.plan.resources,
vec![ResourceSpec {
name: "sample".into(),
url: "https://example.com/sample-1.0.0.zip".into(),
sha256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".into(),
stage_to: None,
}]
);
assert_eq!(
r.plan.steps,
vec![
InstallStep::StageResource {
name: "sample".into(),
dir: None
},
InstallStep::PrefixInstall {
from: ".".into(),
to: None
},
]
);
}
#[test]
fn install_chocolatey_package_exe_is_unsupported() {
let ps1 = r"$packageArgs = @{
packageName = 'sample'
fileType = 'exe'
url = 'https://example.com/sample-setup.exe'
silentArgs = '/S'
validExitCodes = @(0)
}
Install-ChocolateyPackage @packageArgs
";
let r = recipe_for("sample", "1.0.0", ps1);
assert!(!r.plan.is_fully_supported());
assert!(r.plan.resources.is_empty());
let constructs = r.plan.unsupported_constructs();
assert_eq!(constructs.len(), 1);
assert!(constructs[0].contains("Install-ChocolateyPackage"));
assert!(constructs[0].contains("https://example.com/sample-setup.exe"));
}
#[test]
fn md5_checksum_type_weakens_verification() {
let ps1 = r#"Install-ChocolateyZipPackage -PackageName 'sample' -Url 'https://example.com/s.zip' -Checksum 'd41d8cd98f00b204e9800998ecf8427e' -ChecksumType 'md5' -UnzipLocation "$(Split-Path -Parent $MyInvocation.MyCommand.Definition)"
"#;
let r = recipe_for("sample", "1.0.0", ps1);
assert!(!r.plan.is_fully_supported());
assert_eq!(
r.plan.resources,
vec![ResourceSpec {
name: "sample".into(),
url: "https://example.com/s.zip".into(),
sha256: String::new(),
stage_to: None,
}]
);
assert_eq!(
r.plan.steps,
vec![
InstallStep::StageResource {
name: "sample".into(),
dir: None
},
InstallStep::PrefixInstall {
from: ".".into(),
to: None
},
InstallStep::Unsupported {
construct: "checksumType md5".into()
},
]
);
}
#[test]
fn malformed_packages_error_cleanly() {
let prefix = Path::new("/tc/sample");
let err = parse_nupkg(b"definitely not a zip archive", prefix).unwrap_err();
assert!(matches!(err, ToolchainError::RegistryError { .. }), "{err}");
let no_nuspec = build_nupkg(&[("tools/chocolateyInstall.ps1", "Write-Host hi")]);
let err = parse_nupkg(&no_nuspec, prefix).unwrap_err();
assert!(
matches!(&err, ToolchainError::RegistryError { message } if message.contains("nuspec")),
"{err}"
);
let nuspec = nuspec_xml("sample", "1.0.0");
let no_ps1 = build_nupkg(&[("sample.nuspec", nuspec.as_str())]);
let err = parse_nupkg(&no_ps1, prefix).unwrap_err();
assert!(
matches!(&err, ToolchainError::RegistryError { message } if message.contains("chocolateyInstall")),
"{err}"
);
}
#[test]
fn install_bin_file_maps_to_bin_install() {
let ps1 = r#"$toolsDir = "$(Split-Path -Parent $MyInvocation.MyCommand.Definition)"
Install-BinFile -Name 'fooctl' -Path "$toolsDir\fooctl.exe"
"#;
let r = recipe_for("sample", "1.0.0", ps1);
assert!(
r.plan.is_fully_supported(),
"{:?}",
r.plan.unsupported_constructs()
);
assert_eq!(
r.plan.steps,
vec![InstallStep::BinInstall {
from: "fooctl.exe".into(),
to: Some("fooctl".into()),
}]
);
}
#[test]
fn opaque_constructs_surface_as_unsupported() {
let ps1 = r#"$pp = Get-PackageParameters
if (Test-Path "C:\old") {
Remove-Item "C:\old" -Recurse -Force
}
Start-ChocolateyProcessAsAdmin 'setup.exe'
"#;
let r = recipe_for("sample", "1.0.0", ps1);
assert!(!r.plan.is_fully_supported());
let constructs = r.plan.unsupported_constructs();
assert_eq!(constructs.len(), 3, "{constructs:?}");
assert!(constructs[0].contains("Get-PackageParameters"));
assert!(constructs[1].starts_with("if "));
assert!(constructs[2].contains("Start-ChocolateyProcessAsAdmin"));
}
}