1use anyhow::{Context as AnyhowContext, Result, anyhow, bail};
2use base64::Engine;
3use base64::engine::general_purpose::STANDARD;
4use pulumi_gestalt_model::{Output, PulumiValue, PulumiValueContent, ToPulumiValue};
5use sha1::{Digest, Sha1};
6use std::borrow::Borrow;
7use std::collections::BTreeMap;
8use std::collections::HashSet;
9use unicode_segmentation::UnicodeSegmentation;
10
11#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
12pub struct Entry<T> {
13 pub key: String,
14 pub value: T,
15}
16
17impl<T> ToPulumiValue for Entry<T>
18where
19 T: ToPulumiValue + Send + Sync + 'static,
20{
21 fn to_pulumi_value(&self) -> impl std::future::Future<Output = PulumiValue> + Send {
22 let key = self.key.clone();
23 let value_future = self.value.to_pulumi_value();
24 async move {
25 let value = value_future.await;
26 let secret = value.secret;
27 let dependencies = value.dependencies.clone();
28 PulumiValue {
29 content: PulumiValueContent::Object(vec![
30 (
31 "key".to_string(),
32 PulumiValue {
33 content: PulumiValueContent::String(key),
34 secret: false,
35 dependencies: HashSet::new(),
36 },
37 ),
38 ("value".to_string(), value),
39 ]),
40 secret,
41 dependencies,
42 }
43 }
44 }
45}
46
47pub fn cwd() -> Result<String> {
48 let path = std::env::current_dir().context("Failed to read current working directory")?;
49
50 path.into_os_string().into_string().map_err(|e| {
51 anyhow!(
52 "Current working directory path is not valid UTF-8 and cannot be represented as a string: [{:?}]",
53 e
54 )
55 })
56}
57
58pub fn to_base64(input: impl AsRef<[u8]>) -> String {
59 STANDARD.encode(input)
60}
61
62pub fn from_base64(input: impl AsRef<str>) -> Result<String> {
63 let bytes = STANDARD
64 .decode(input.as_ref())
65 .context("Failed to decode base64 data")?;
66
67 String::from_utf8(bytes).context(
68 "Decoded base64 data is not valid UTF-8 string. \
69 The data may be binary content that cannot be represented as a string.",
70 )
71}
72
73fn pulumi_content_to_json_value(content: PulumiValueContent) -> Option<serde_json::Value> {
74 match content {
75 PulumiValueContent::String(value) => Some(serde_json::Value::String(value)),
76 PulumiValueContent::Integer(value) => Some(serde_json::Value::from(value)),
77 PulumiValueContent::Number(value) => Some(serde_json::Value::from(value)),
78 PulumiValueContent::Boolean(value) => Some(serde_json::Value::from(value)),
79 PulumiValueContent::Array(values) => values
80 .into_iter()
81 .map(|value| pulumi_content_to_json_value(value.content))
82 .collect::<Option<Vec<_>>>()
83 .map(serde_json::Value::Array),
84 PulumiValueContent::Object(fields) => fields
85 .into_iter()
86 .map(|(key, value)| pulumi_content_to_json_value(value.content).map(|v| (key, v)))
87 .collect::<Option<serde_json::Map<_, _>>>()
88 .map(serde_json::Value::Object),
89 PulumiValueContent::None => Some(serde_json::Value::Null),
90 PulumiValueContent::Nothing => None,
91 }
92}
93
94pub fn to_json<T>(value: &T) -> Output<String>
95where
96 T: ToPulumiValue + Clone + Send + Sync + 'static,
97{
98 let value = value.clone();
99 Output::from_resolved_future(async move {
100 let pulumi_value = value.to_pulumi_value().await;
101 let secret = pulumi_value.secret;
102 let dependencies = pulumi_value.dependencies.clone();
103 match pulumi_content_to_json_value(pulumi_value.content) {
104 None => pulumi_gestalt_model::ResolvedOutput {
105 value: None,
106 secret,
107 dependencies,
108 },
109 Some(json) => pulumi_gestalt_model::ResolvedOutput {
110 value: Some(json.to_string()),
111 secret,
112 dependencies,
113 },
114 }
115 })
116}
117
118pub fn sha1(input: impl AsRef<str>) -> String {
119 let hash = Sha1::digest(input.as_ref().as_bytes());
120 hex::encode(hash)
121}
122
123pub fn read_file(path: impl AsRef<str>) -> Result<String> {
124 std::fs::read_to_string(path.as_ref())
125 .with_context(|| format!("Failed to read file: {}", path.as_ref()))
126}
127
128pub fn filebase64(path: impl AsRef<str>) -> Result<String> {
129 let bytes = std::fs::read(path.as_ref())
130 .with_context(|| format!("Failed to read file: {}", path.as_ref()))?;
131 Ok(STANDARD.encode(bytes))
132}
133
134pub fn filebase64sha256(path: impl AsRef<str>) -> Result<String> {
135 use sha2::{Digest as Sha2Digest, Sha256};
136 let bytes = std::fs::read(path.as_ref())
137 .with_context(|| format!("Failed to read file: {}", path.as_ref()))?;
138 let hash = Sha256::digest(&bytes);
139 Ok(STANDARD.encode(hash))
140}
141
142pub fn element<T: Clone, I: TryInto<i64>>(list: impl AsRef<[T]>, index: I) -> Result<T> {
143 let index = index
144 .try_into()
145 .map_err(|_| anyhow!("Failed to convert list index to i64"))?;
146 if index < 0 {
147 bail!("List index cannot be negative: {index}");
148 }
149 let index = usize::try_from(index).context("Failed to convert list index to usize")?;
150 let list_ref = list.as_ref();
151 list_ref.get(index).cloned().ok_or_else(|| {
152 anyhow!(
153 "List index {index} is out of bounds for length {}",
154 list_ref.len()
155 )
156 })
157}
158
159pub fn join<T: AsRef<str>>(separator: impl AsRef<str>, list: impl AsRef<[T]>) -> String {
160 list.as_ref()
161 .iter()
162 .map(AsRef::as_ref)
163 .collect::<Vec<_>>()
164 .join(separator.as_ref())
165}
166
167pub fn length<T>(list: impl AsRef<[T]>) -> i64 {
168 i64::try_from(list.as_ref().len()).expect("List length exceeds i64::MAX")
169}
170
171pub fn length_string(input: impl AsRef<str>) -> i64 {
172 i64::try_from(input.as_ref().graphemes(true).count()).expect("String length exceeds i64::MAX")
173}
174
175pub fn split(separator: impl AsRef<str>, text: impl AsRef<str>) -> Vec<String> {
176 text.as_ref()
177 .split(separator.as_ref())
178 .map(ToOwned::to_owned)
179 .collect()
180}
181
182pub fn single_or_none<T: Clone>(list: impl AsRef<[T]>) -> Result<Option<T>> {
183 let list_ref = list.as_ref();
184 if list_ref.is_empty() {
185 return Ok(None);
186 }
187 if list_ref.len() != 1 {
188 bail!(
189 "singleOrNone expected input list to have at most one element, got {}",
190 list_ref.len()
191 );
192 }
193 Ok(Some(list_ref[0].clone()))
194}
195
196pub fn entries<T: Clone>(map: impl Borrow<BTreeMap<String, T>>) -> Vec<Entry<T>> {
197 map.borrow()
198 .iter()
199 .map(|(key, value)| Entry {
200 key: key.clone(),
201 value: value.clone(),
202 })
203 .collect()
204}
205
206pub fn lookup<K, V, Q>(map: impl Borrow<BTreeMap<K, V>>, key: &Q, default: impl Into<V>) -> V
207where
208 K: Borrow<Q> + Ord,
209 Q: Ord + ?Sized,
210 V: Clone,
211{
212 map.borrow()
213 .get(key)
214 .cloned()
215 .unwrap_or_else(|| default.into())
216}
217
218#[cfg(test)]
219mod tests {
220 use super::{
221 Entry, cwd, element, entries, filebase64, filebase64sha256, from_base64, join, length,
222 length_string, lookup, read_file, sha1, single_or_none, split, to_base64, to_json,
223 };
224 use crate::pulumi_any;
225 use futures::executor::block_on;
226 use pulumi_gestalt_model::{Output, PulumiValueContent, ToPulumiValue};
227 use std::collections::BTreeMap;
228
229 #[test]
230 fn to_base64_encodes_known_text() {
231 assert_eq!(to_base64("hello"), "aGVsbG8=");
232 }
233
234 #[test]
235 fn cwd_matches_std_env_current_dir() {
236 let expected = std::env::current_dir()
237 .unwrap()
238 .into_os_string()
239 .into_string()
240 .unwrap();
241 let actual = cwd().unwrap();
242
243 assert_eq!(actual, expected);
244 }
245
246 #[test]
247 fn from_base64_decodes_known_text() {
248 assert_eq!(from_base64("aGVsbG8=").unwrap(), "hello");
249 }
250
251 #[test]
252 fn roundtrip_text_data() {
253 let text = "Hello, World! ๐";
254 let encoded = to_base64(text);
255 let decoded = from_base64(encoded).unwrap();
256
257 assert_eq!(decoded, text);
258 }
259
260 #[test]
261 fn from_base64_binary_data_returns_error() {
262 let payload = vec![0x00, 0xff, 0x10, 0x41];
264 let encoded = to_base64(&payload);
265 let result = from_base64(encoded);
266
267 assert!(result.is_err());
268 let error_msg = result.unwrap_err().to_string();
269 assert!(error_msg.contains("not valid UTF-8"));
270 assert!(error_msg.contains("binary content"));
271 }
272
273 #[test]
274 fn from_base64_invalid_input_returns_error() {
275 assert!(from_base64("%%%").is_err());
276 }
277
278 #[test]
279 fn to_json_serializes_scalars() {
280 let string_resolved = block_on(to_json(&"hello").resolve());
281 let number_resolved = block_on(to_json(&42.5f64).resolve());
282 let bool_resolved = block_on(to_json(&true).resolve());
283
284 assert_eq!(string_resolved.value, Some("\"hello\"".to_string()));
285 assert_eq!(number_resolved.value, Some("42.5".to_string()));
286 assert_eq!(bool_resolved.value, Some("true".to_string()));
287 }
288
289 #[test]
290 fn to_json_serializes_arrays_and_objects() {
291 let value = pulumi_any!({
292 "items": ["x", "y", "z"],
293 "metadata": {"count": 3}
294 });
295 let resolved = block_on(to_json(&value).resolve());
296
297 let parsed: serde_json::Value =
298 serde_json::from_str(resolved.value.as_ref().unwrap()).unwrap();
299 assert_eq!(
300 parsed,
301 serde_json::json!({
302 "items": ["x", "y", "z"],
303 "metadata": {"count": 3}
304 })
305 );
306 }
307
308 #[test]
309 fn to_json_serializes_none_as_null() {
310 let none_value: Option<String> = None;
311 let resolved = block_on(to_json(&none_value).resolve());
312 assert_eq!(resolved.value, Some("null".to_string()));
313 }
314
315 #[test]
316 fn to_json_returns_unknown_for_root_nothing() {
317 let value: Output<String> = Output::new_nothing();
318 let resolved = block_on(to_json(&value).resolve());
319 assert_eq!(resolved.value, None);
320 }
321
322 #[test]
323 fn to_json_returns_unknown_for_nested_nothing() {
324 let value = pulumi_any!({
325 "known": "value",
326 "unknown": Output::<String>::new_nothing(),
327 });
328 let resolved = block_on(to_json(&value).resolve());
329 assert_eq!(resolved.value, None);
330 }
331
332 #[test]
333 fn to_json_returns_unknown_for_deeply_nested_nothing() {
334 let value = pulumi_any!({
335 "l1": {
336 "l2": [
337 {"l3": "still-known"},
338 {"l3": Output::<String>::new_nothing()},
339 ]
340 }
341 });
342 let resolved = block_on(to_json(&value).resolve());
343 assert_eq!(resolved.value, None);
344 }
345
346 #[test]
347 fn to_json_preserves_secret_on_root_secret() {
348 let value = Output::new_secret("top-secret".to_string());
349 let resolved = block_on(to_json(&value).resolve());
350 assert_eq!(resolved.value, Some("\"top-secret\"".to_string()));
351 assert!(resolved.secret);
352 }
353
354 #[test]
355 fn to_json_preserves_secret_for_nested_secret() {
356 let value = pulumi_any!({
357 "public": "ok",
358 "secret": Output::new_secret("hidden".to_string()),
359 });
360 let resolved = block_on(to_json(&value).resolve());
361 assert!(resolved.secret);
362 }
363
364 #[test]
365 fn to_json_preserves_secret_for_deeply_nested_secret() {
366 let value = pulumi_any!({
367 "l1": {
368 "l2": [
369 {"l3": "visible"},
370 {"l3": Output::new_secret("hidden".to_string())},
371 ]
372 }
373 });
374 let resolved = block_on(to_json(&value).resolve());
375 assert!(resolved.secret);
376 }
377
378 #[test]
379 fn to_json_unknown_and_secret_nested_results_in_unknown_secret_output() {
380 let value = pulumi_any!({
381 "l1": {
382 "unknown": Output::<String>::new_nothing(),
383 "l2": {
384 "secret": Output::new_secret("hidden".to_string()),
385 }
386 }
387 });
388 let resolved = block_on(to_json(&value).resolve());
389 assert_eq!(resolved.value, None);
390 assert!(resolved.secret);
391 }
392
393 #[test]
394 fn to_base64_accepts_multiple_input_types() {
395 let string = "xyz".to_string();
396 let from_string = to_base64(string);
397 let from_str = to_base64("abc");
398 let from_bytes = to_base64(vec![97_u8, 98_u8, 99_u8]);
399
400 assert_eq!(from_string, "eHl6");
401 assert_eq!(from_str, "YWJj");
402 assert_eq!(from_str, from_bytes);
403 }
404
405 #[test]
406 fn sha1_hashes_known_values() {
407 assert_eq!(
408 sha1("hello world"),
409 "2aae6c35c94fcfb415dbe95f408b9ce91ee846ed"
410 );
411 assert_eq!(
412 sha1("goodbye world"),
413 "0078bb8e5c9d8abf7f1e4e14c87d9023235b6230"
414 );
415 }
416
417 #[test]
418 fn read_file_reads_existing_file() {
419 use std::io::Write;
420 let mut tmp = tempfile::NamedTempFile::new().unwrap();
421 write!(tmp, "hello from file").unwrap();
422 let result = read_file(tmp.path().to_str().unwrap()).unwrap();
423 assert_eq!(result, "hello from file");
424 }
425
426 #[test]
427 fn read_file_returns_error_for_missing_file() {
428 let result = read_file("/nonexistent/path/file.txt");
429 assert!(result.is_err());
430 let msg = result.unwrap_err().to_string();
431 assert!(msg.contains("Failed to read file"));
432 }
433
434 #[test]
435 fn filebase64_encodes_file_contents() {
436 use std::io::Write;
437 let mut tmp = tempfile::NamedTempFile::new().unwrap();
438 write!(tmp, "hello").unwrap();
439 let result = filebase64(tmp.path().to_str().unwrap()).unwrap();
440 assert_eq!(result, "aGVsbG8=");
441 }
442
443 #[test]
444 fn filebase64sha256_hashes_known_content() {
445 use std::io::Write;
446 let mut tmp = tempfile::NamedTempFile::new().unwrap();
448 write!(tmp, "The quick brown fox jumps over the lazy dog").unwrap();
449 let result = filebase64sha256(tmp.path().to_str().unwrap()).unwrap();
450 assert_eq!(result, "16j7swfXgJRpypq8sAguT41WUeRtPNt2LQLQvzfJ5ZI=");
451 }
452
453 #[test]
454 fn element_returns_item_for_valid_index() {
455 let values = vec!["a".to_string(), "b".to_string(), "c".to_string()];
456 assert_eq!(element(&values, 1).unwrap(), "b");
457 }
458
459 #[test]
460 fn element_returns_error_for_negative_index() {
461 let values = vec!["a", "b"];
462 let error = element(&values, -1).unwrap_err().to_string();
463 assert!(error.contains("cannot be negative"));
464 }
465
466 #[test]
467 fn element_returns_error_for_out_of_bounds_index() {
468 let values = vec!["a", "b"];
469 let error = element(&values, 2).unwrap_err().to_string();
470 assert!(error.contains("out of bounds"));
471 }
472
473 #[test]
474 fn join_joins_values_with_separator() {
475 let values = vec!["a".to_string(), "b".to_string(), "c".to_string()];
476 assert_eq!(join("|", &values), "a|b|c");
477 }
478
479 #[test]
480 fn length_returns_i64_length() {
481 let values = vec!["x", "y", "z"];
482 assert_eq!(length(&values), 3_i64);
483 }
484
485 #[test]
486 fn length_string_counts_ascii_graphemes() {
487 assert_eq!(length_string("abcd"), 4_i64);
488 }
489
490 #[test]
491 fn length_string_counts_combining_marks_as_single_grapheme() {
492 let text = "a\u{0301}";
493 assert_eq!(length_string(text), 1_i64);
494 }
495
496 #[test]
497 fn length_string_counts_zwj_emoji_as_single_grapheme() {
498 let text = "๐จโ๐ฉโ๐งโ๐ฆ";
499 assert_eq!(length_string(text), 1_i64);
500 }
501
502 #[test]
503 fn length_string_counts_mixed_text_by_graphemes() {
504 let text = "A๐ฉโ๐ปB";
505 assert_eq!(length_string(text), 3_i64);
506 }
507
508 #[test]
509 fn length_list_behavior_is_unchanged() {
510 let values = vec![0_u8, 1_u8, 2_u8, 3_u8];
511 assert_eq!(length(&values), 4_i64);
512 }
513
514 #[test]
515 fn split_returns_segments() {
516 assert_eq!(split("-", "a-b-c"), vec!["a", "b", "c"]);
517 }
518
519 #[test]
520 fn single_or_none_returns_none_for_empty_list() {
521 let values: Vec<String> = vec![];
522 assert_eq!(single_or_none(&values).unwrap(), None);
523 }
524
525 #[test]
526 fn single_or_none_returns_some_for_single_item_list() {
527 let values = vec!["only".to_string()];
528 assert_eq!(single_or_none(&values).unwrap(), Some("only".to_string()));
529 }
530
531 #[test]
532 fn single_or_none_returns_error_for_multi_item_list() {
533 let values = vec!["a", "b"];
534 let error = single_or_none(&values).unwrap_err().to_string();
535 assert!(error.contains("at most one element"));
536 }
537
538 #[test]
539 fn entries_returns_key_value_pairs_for_non_empty_map() {
540 let mut map = BTreeMap::new();
541 map.insert("b".to_string(), "2".to_string());
542 map.insert("a".to_string(), "1".to_string());
543
544 let output = entries(Box::new(map));
545
546 assert_eq!(
547 output,
548 vec![
549 Entry {
550 key: "a".to_string(),
551 value: "1".to_string(),
552 },
553 Entry {
554 key: "b".to_string(),
555 value: "2".to_string(),
556 }
557 ]
558 );
559 }
560
561 #[test]
562 fn entries_returns_empty_vec_for_empty_map() {
563 let map: BTreeMap<String, String> = BTreeMap::new();
564 assert_eq!(entries(Box::new(map)), Vec::<Entry<String>>::new());
565 }
566
567 #[test]
568 fn entries_returns_empty_vec_for_empty_map_borrow() {
569 let map: BTreeMap<String, String> = BTreeMap::new();
570 assert_eq!(entries(&map), Vec::<Entry<String>>::new());
571 }
572
573 #[test]
574 fn entries_accepts_owned_map() {
575 let map: BTreeMap<String, String> = BTreeMap::new();
576 assert_eq!(entries(map), Vec::<Entry<String>>::new());
577 }
578
579 #[test]
580 fn lookup_returns_value_for_existing_key() {
581 let mut map = BTreeMap::new();
582 map.insert("answer".to_string(), "42".to_string());
583
584 assert_eq!(lookup(Box::new(map), "answer", "default"), "42".to_string());
585 }
586
587 #[test]
588 fn lookup_returns_default_for_missing_key() {
589 let mut map = BTreeMap::new();
590 map.insert("answer".to_string(), "42".to_string());
591
592 assert_eq!(
593 lookup(Box::new(map), "missing", "default"),
594 "default".to_string()
595 );
596 }
597
598 #[test]
599 fn entries_are_convertible_to_pulumi_values() {
600 let mut map = BTreeMap::new();
601 map.insert("a".to_string(), 1i32);
602 let values = entries(&map);
603 let output = block_on(values.to_pulumi_value());
604
605 assert!(matches!(output.content, PulumiValueContent::Array(_)));
606 }
607}