Repository: casey/just
Pull request #1170 (2022-05-01 21:10:07)

UUID and sha256 digest functions
issue #1018 mentioned a uuid function would be helpful.

Here is my attempt to deliver this. Also I've included sha256 hashing functions for strings and files.

To create a meaningful unittest for uuid() I had to refactor the Test struct in test.rs, I hope this is acceptable. The challenge was to keep the current interface but allow for custom verification. Well, here is my attempt. I'm new to rust, so bear with me.

casey: Thanks for this PR! There's already `Test::stderr_regex`, and I think it might be cleaner to add a `Test::stdout_regex` to test that the output matches a pattern, instead of adding a new kind of validator. for UUIDs, it should be okay to check that it matches the pattern `/........-....-....-....-............/`

For the hash functions, can you use the implementation from https://github.com/RustCrypto/hashes? I think it's better maintained.
mbodmer: Hi, thanks for your review. I have now added stdout_regex in the sense of the existing stderr_regex. I agree, much less complexity that way. Also I have replaced the hashing dependency with rust-crypto. Hope this helps.
mbodmer: I'm not sure whether the general name digest()/digest_file() for the functions is too broad. Any requests for a better more concise name? :-)
eg: sha256()/sha256_file()
casey: > I'm not sure whether the general name digest()/digest_file() for the functions is too broad. Any requests for a better more concise name? :-) eg: sha256()/sha256_file()

I think I agree, `sha256()` and `sha256_file()` are probably better, so if you're reading a justfile and you don't know the function you can see what it does without having to look up documentation.
mbodmer: Ok, they are now renamed to the more specific `sha256()`, `sha256_file()`
mbodmer: Sorry for force pushing, I've had to rewrite the history to rebase the branch, since you've also modified the lockfile and stupid me checked it in, in a commit together with other changes.
Can you give me more details, I don't understand where, how and what causes the bug you've mentioned.
casey: Lets say you have the following files:

```
/justfile
/bar
/foo/
```

And you're currently in `/foo/`. `/justfile` contains:

```
baz:
  echo {{sha256_file("bar")}}}
```

You do:

```
$ just
```

The `just` executable looks for the justfile and sees that it's in `/`, so it sets `search.working_directory` to `/`. However, it does not call `env::set_current_dir` with `/`, so the current directory is still `/bar`. So then `sha256_file("bar")` will fail, because there is no `/foo/bar`.
mbodmer: Ok, I think now I understand, thanks. Will try

Patch:
diff --git a/Cargo.lock b/Cargo.lock
index f7e0e4cf..47701dfe 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -37,6 +37,15 @@ version = "1.3.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
 
+[[package]]
+name = "block-buffer"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf7fe51849ea569fd452f37822f606a5cabb684dc918707a0193fd4664ff324"
+dependencies = [
+ "generic-array",
+]
+
 [[package]]
 name = "bstr"
 version = "0.2.17"
@@ -76,6 +85,15 @@ dependencies = [
  "vec_map",
 ]
 
+[[package]]
+name = "cpufeatures"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59a6001667ab124aebae2a495118e11d30984c3a653e99d86d58971708cf5e4b"
+dependencies = [
+ "libc",
+]
+
 [[package]]
 name = "cradle"
 version = "0.2.2"
@@ -85,6 +103,16 @@ dependencies = [
  "rustversion",
 ]
 
+[[package]]
+name = "crypto-common"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57952ca27b5e3606ff4dd79b0020231aaf9d6aa76dc05fd30137538c50bd3ce8"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
 [[package]]
 name = "ctor"
 version = "0.1.22"
@@ -122,6 +150,16 @@ version = "0.1.12"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0e25ea47919b1560c4e3b7fe0aaab9becf5b84a10325ddf7db0f0ba5e1026499"
 
+[[package]]
+name = "digest"
+version = "0.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2fb860ca6fafa5552fb6d0e816a69c8e49f0908bf524e30a90d97c85892d506"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
 [[package]]
 name = "doc-comment"
 version = "0.3.3"
@@ -182,6 +220,16 @@ dependencies = [
  "pulldown-cmark-to-cmark",
 ]
 
+[[package]]
+name = "generic-array"
+version = "0.14.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd48d33ec7f05fbfa152300fdad764757cbded343c1aa1cff2fbaf4134851803"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
 [[package]]
 name = "getopts"
 version = "0.2.21"
@@ -191,6 +239,17 @@ dependencies = [
  "unicode-width",
 ]
 
+[[package]]
+name = "getrandom"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9be70c98951c83b8d2f8f60d7065fa6d5146873094452a1008da8c2f1e4205ad"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
 [[package]]
 name = "heck"
 version = "0.3.3"
@@ -259,6 +318,7 @@ dependencies = [
  "regex",
  "serde",
  "serde_json",
+ "sha2",
  "similar",
  "snafu",
  "strum",
@@ -267,6 +327,7 @@ dependencies = [
  "temptree",
  "typed-arena",
  "unicode-width",
+ "uuid",
  "which",
  "yaml-rust",
 ]
@@ -498,6 +559,17 @@ dependencies = [
  "serde",
 ]
 
+[[package]]
+name = "sha2"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55deaec60f81eefe3cce0dc50bda92d6d8e88f2a27df7c5033b42afeb1ed2676"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
 [[package]]
 name = "similar"
 version = "2.1.0"
@@ -657,6 +729,12 @@ version = "2.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0685c84d5d54d1c26f7d3eb96cd41550adb97baed141a761cf335d3d33bcd0ae"
 
+[[package]]
+name = "typenum"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dcf81ac59edc17cc8697ff311e8f5ef2d99fcbd9817b34cec66f90b6c3dfd987"
+
 [[package]]
 name = "unicase"
 version = "2.6.0"
@@ -684,6 +762,15 @@ version = "0.2.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "957e51f3646910546462e67d5f7599b9e4fb8acdd304b087a6494730f9eebf04"
 
+[[package]]
+name = "uuid"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8cfcd319456c4d6ea10087ed423473267e1a071f3bc0aa89f80d60997843c6f0"
+dependencies = [
+ "getrandom",
+]
+
 [[package]]
 name = "vec_map"
 version = "0.8.2"
@@ -696,6 +783,12 @@ version = "0.9.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
 
+[[package]]
+name = "wasi"
+version = "0.10.2+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6"
+
 [[package]]
 name = "which"
 version = "4.2.5"
diff --git a/Cargo.toml b/Cargo.toml
index 0e343ff0..50e16423 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -20,7 +20,7 @@ ansi_term = "0.12.0"
 atty = "0.2.0"
 camino = "1.0.4"
 clap = { version = "2.33.0", features = ["wrap_help"] }
-ctrlc = { version  = "3.1.1", features = ["termination"] }
+ctrlc = { version = "3.1.1", features = ["termination"] }
 derivative = "2.0.0"
 dotenv = "0.15.0"
 edit-distance = "2.0.0"
@@ -32,13 +32,15 @@ log = "0.4.4"
 regex = "1.5.4"
 serde = { version = "1.0.130", features = ["derive", "rc"] }
 serde_json = "1.0.68"
+sha2 = "0.10"
 similar = { version = "2.1.0", features = ["unicode"] }
 snafu = "0.7.0"
-strum = { version  = "0.24.0", features = ["derive"] }
+strum = { version = "0.24.0", features = ["derive"] }
 target = "2.0.0"
 tempfile = "3.0.0"
 typed-arena = "2.0.1"
 unicode-width = "0.1.0"
+uuid = { version = "1.0.0", features = ["v4"] }
 
 [dev-dependencies]
 cradle = "0.2.0"
diff --git a/README.md b/README.md
index f5d9272b..5ad63df0 100644
--- a/README.md
+++ b/README.md
@@ -1030,6 +1030,12 @@ These functions can fail, for example if a path does not have an extension, whic
 
 - `error(message)` - Abort execution and report error `message` to user.
 
+#### UUID and Hash Generation
+
+- `sha256(string)` - Return the SHA-256 hash of `string` as a hexadecimal string.
+- `sha256_file(path)` - Return the SHA-256 hash of the file at `path` as a hexadecimal string.
+- `uuid()` - Return a randomly generated UUID.
+
 ### Command Evaluation Using Backticks
 
 Backticks can be used to store the result of commands:
diff --git a/src/function.rs b/src/function.rs
index ccdb8a53..71550981 100644
--- a/src/function.rs
+++ b/src/function.rs
@@ -35,6 +35,8 @@ lazy_static! {
     ("path_exists", Unary(path_exists)),
     ("quote", Unary(quote)),
     ("replace", Ternary(replace)),
+    ("sha256", Unary(sha256)),
+    ("sha256_file", Unary(sha256_file)),
     ("trim", Unary(trim)),
     ("trim_end", Unary(trim_end)),
     ("trim_end_match", Binary(trim_end_match)),
@@ -43,6 +45,7 @@ lazy_static! {
     ("trim_start_match", Binary(trim_start_match)),
     ("trim_start_matches", Binary(trim_start_matches)),
     ("uppercase", Unary(uppercase)),
+    ("uuid", Nullary(uuid)),
     ("without_extension", Unary(without_extension)),
   ]
   .into_iter()
@@ -247,6 +250,26 @@ fn replace(_context: &FunctionContext, s: &str, from: &str, to: &str) -> Result<
   Ok(s.replace(from, to))
 }
 
+fn sha256(_context: &FunctionContext, s: &str) -> Result<String, String> {
+  use sha2::{Digest, Sha256};
+  let mut hasher = Sha256::new();
+  hasher.update(s);
+  let hash = hasher.finalize();
+  Ok(format!("{:x}", hash))
+}
+
+fn sha256_file(context: &FunctionContext, path: &str) -> Result<String, String> {
+  use sha2::{Digest, Sha256};
+  let justpath = context.search.working_directory.join(path);
+  let mut hasher = Sha256::new();
+  let mut file = std::fs::File::open(&justpath)
+    .map_err(|err| format!("Failed to open file at `{:?}`: {}", &justpath.to_str(), err))?;
+  std::io::copy(&mut file, &mut hasher)
+    .map_err(|err| format!("Failed to read file at `{:?}`: {}", &justpath.to_str(), err))?;
+  let hash = hasher.finalize();
+  Ok(format!("{:x}", hash))
+}
+
 fn trim(_context: &FunctionContext, s: &str) -> Result<String, String> {
   Ok(s.trim().to_owned())
 }
@@ -279,6 +302,10 @@ fn uppercase(_context: &FunctionContext, s: &str) -> Result<String, String> {
   Ok(s.to_uppercase())
 }
 
+fn uuid(_context: &FunctionContext) -> Result<String, String> {
+  Ok(uuid::Uuid::new_v4().to_string())
+}
+
 fn without_extension(_context: &FunctionContext, path: &str) -> Result<String, String> {
   let parent = Utf8Path::new(path)
     .parent()


Test patch:
diff --git a/tests/functions.rs b/tests/functions.rs
index 713f1dde..e5f8d351 100644
--- a/tests/functions.rs
+++ b/tests/functions.rs
@@ -476,3 +476,36 @@ fn path_exists_subdir() {
     .stdout("true")
     .run();
 }
+
+#[test]
+fn uuid() {
+  Test::new()
+    .justfile("x := uuid()")
+    .args(&["--evaluate", "x"])
+    .stdout_regex("........-....-....-....-............")
+    .run();
+}
+
+#[test]
+fn sha256() {
+  Test::new()
+    .justfile("x := sha256('5943ee37-0000-1000-8000-010203040506')")
+    .args(&["--evaluate", "x"])
+    .stdout("2330d7f5eb94a820b54fed59a8eced236f80b633a504289c030b6a65aef58871")
+    .run();
+}
+
+#[test]
+fn sha256_file() {
+  Test::new()
+    .justfile("x := sha256_file('sub/shafile')")
+    .tree(tree! {
+      sub: {
+        shafile: "just is great\n",
+      }
+    })
+    .current_dir("sub")
+    .args(&["--evaluate", "x"])
+    .stdout("177b3d79aaafb53a7a4d7aaba99a82f27c73370e8cb0295571aade1e4fea1cd2")
+    .run();
+}
diff --git a/tests/test.rs b/tests/test.rs
index 4673ad2f..a126fb58 100644
--- a/tests/test.rs
+++ b/tests/test.rs
@@ -10,6 +10,7 @@ macro_rules! test {
     $(env: { $($env_key:literal : $env_value:literal,)* },)?
     $(stdin: $stdin:expr,)?
     $(stdout: $stdout:expr,)?
+    $(stdout_regex: $stdout_regex:expr,)?
     $(stderr: $stderr:expr,)?
     $(stderr_regex: $stderr_regex:expr,)?
     $(status: $status:expr,)?
@@ -28,6 +29,7 @@ macro_rules! test {
       $(let test = test.stderr_regex($stderr_regex);)?
       $(let test = test.stdin($stdin);)?
       $(let test = test.stdout($stdout);)?
+      $(let test = test.stdout_regex($stdout_regex);)?
 
       test.run();
     }
@@ -45,6 +47,7 @@ pub(crate) struct Test {
   pub(crate) stderr_regex: Option<Regex>,
   pub(crate) stdin: String,
   pub(crate) stdout: String,
+  pub(crate) stdout_regex: Option<Regex>,
   pub(crate) tempdir: TempDir,
   pub(crate) unindent_stdout: bool,
 }
@@ -66,6 +69,7 @@ impl Test {
       stderr_regex: None,
       stdin: String::new(),
       stdout: String::new(),
+      stdout_regex: None,
       tempdir,
       unindent_stdout: true,
     }
@@ -137,6 +141,11 @@ impl Test {
     self
   }
 
+  pub(crate) fn stdout_regex(mut self, stdout_regex: impl AsRef<str>) -> Self {
+    self.stdout_regex = Some(Regex::new(&format!("(?m)^{}$", stdout_regex.as_ref())).unwrap());
+    self
+  }
+
   pub(crate) fn tree(self, mut tree: Tree) -> Self {
     tree.map(|_name, content| unindent(content));
     tree.instantiate(self.tempdir.path()).unwrap();
@@ -203,8 +212,18 @@ impl Test {
       equal
     }
 
+    let output_stdout = str::from_utf8(&output.stdout).unwrap();
     let output_stderr = str::from_utf8(&output.stderr).unwrap();
 
+    if let Some(ref stdout_regex) = self.stdout_regex {
+      if !stdout_regex.is_match(output_stdout) {
+        panic!(
+          "Stdout regex mismatch:\n{:?}\n!~=\n/{:?}/",
+          output_stderr, stdout_regex
+        );
+      }
+    }
+
     if let Some(ref stderr_regex) = self.stderr_regex {
       if !stderr_regex.is_match(output_stderr) {
         panic!(
@@ -215,7 +234,7 @@ impl Test {
     }
 
     if !compare("status", output.status.code().unwrap(), self.status)
-      | !compare("stdout", str::from_utf8(&output.stdout).unwrap(), &stdout)
+      | (self.stdout_regex.is_none() && !compare("stdout", output_stdout, &stdout))
       | (self.stderr_regex.is_none() && !compare("stderr", output_stderr, &stderr))
     {
       panic!("Output mismatch.");