Repository: zizmorcore/zizmor (Rust)

Issue:
Linting of top level action.yml fails after zizmor>=0.13.0
Since [zizmor `0.13.0`](https://github.com/zizmorcore/zizmor/releases/tag/v1.13.0), it's no longer possible to use `zizmor action.yml` to validate the top-level action file in a GitHub Action repository:

```bash
zizmor action.yml 

fatal: no audit was performed
error: configuration error in action.yml

Caused by:
    0: configuration error in action.yml
    1: I/O error: No such file or directory (os error 2)
    2: No such file or directory (os error 2)

```

The correct way to lint this single file now is apparently to use `zizmor ./action.yml`.  
As such, the current pre-commit hook does not work, since pre-commit passes the top-level `action.yml` as-is.

My workaround was to disable file matching altogether and rely solely on zizmor's file discovery:

```yaml
  - repo: https://github.com/zizmorcore/zizmor-pre-commit
    rev: v1.14.2
    hooks:
      - id: zizmor
        pass_filenames: false
        args:
          - "."
```



Discussion:
Hi @thurse93, thanks for opening an issue.

To clarify: can you share the exact `zizmor` invocation you ran? Were you able to reproduce the error outside of `zizmor` being invoked by `pre-commit`?

(FWIW, I would definitely recommend using the file discovery default -- future versions of `zizmor` may audit additional types of files, so using the discovery means you won't accidentally limit the effectiveness/scope of your scans 🙂. But regardless, this may still be a bug!)
Oh hmm, I was able to reproduce this with `zizmor action.yml` as well (in a directory that definitely contains a valid action).

I'm not 100% sure why that regressed and why `./action.yml` works, but that seems quite bad. I'm going to move this to the main zizmor issue tracker to triage further later today.
Okay, I see what's happening here: when the user does `zizmor action.yml` we attempt config discovery, starting at the parent of the given input. In this case `Path("action.yml").parent()` is `Some("")`, which is a special sentinel value that Rust returns for exactly this kind of "bare path" case:

https://doc.rust-lang.org/std/path/struct.Path.html#method.parent

Whereas when given `./action.yml`, `parent()` is `.` and we continue with discovery as expected.

I find this behavior pretty surprising on Rust's behalf (I would expect `parent` to be `None` here, but maybe I'm not thinking about it hard enough), but it's documented. 

I think the fix here is to canonicalize the parent path before the discovery step, so that `discover_in_dir` is always given a real path and not `""`. 
Hey @woodruffw thanks for the quick reply and investigations! I'm unfamiliar with the whole zizmor file discovery process and what should be right or wrong behavior here, so I just opened it in the pre-commit repo since this is what I use.

Btw, if you say that one shouldn't rely on pre-commit's file pre-filtering when using zizmor, I'd suggest to adjust the pre-commit hook accordingly. Currently, only yaml-files in certain directories and files named `action.y(a)ml` are being presented to zizmor:

```yaml
  types: [yaml]
  files: (\.github/workflows/.*)|(action\.ya?ml)$
```

See https://github.com/zizmorcore/zizmor-pre-commit/blob/3c10df247c55cf21f75003105b879f145096bd4a/.pre-commit-hooks.yaml#L5-L6

So i guess that

```yaml
        pass_filenames: false
        args:
          - "."
```

should be the new standard then?
> Btw, if you say that one shouldn't rely on pre-commit's file pre-filtering when using zizmor, I'd suggest to adjust the pre-commit hook accordingly. Currently, only yaml-files in certain directories and files named `action.y(a)ml` are being presented to zizmor:

Oh huh, I didn't realize that was still the default there. Yeah, I think it should be changed like you say 🙂 
#1212 will fix this.



Patch:
diff --git a/crates/zizmor/src/config.rs b/crates/zizmor/src/config.rs
index dd3f92a7a..c35dcaf6c 100644
--- a/crates/zizmor/src/config.rs
+++ b/crates/zizmor/src/config.rs
@@ -473,9 +473,17 @@ impl Config {
                 source: err,
             })
         } else {
-            let Some(parent) = path.parent() else {
-                tracing::debug!("no parent for {path:?}, cannot discover config");
-                return Ok(None);
+            let parent = match path.parent().map(|p| p.as_str()) {
+                // NOTE(ww): Annoying: `parent()` returns `None` for root paths,
+                // but `Some("")` for paths like `action.yml` (i.e. no parent dir).
+                // We have to handle this sentinel case explicitly, since
+                // `canonicalize("")` isn't valid.
+                Some("") => Utf8Path::new("."),
+                Some(p) => p.into(),
+                None => {
+                    tracing::debug!("no parent for {path:?}, cannot discover config");
+                    return Ok(None);
+                }
             };
 
             Self::discover_in_dir(parent).map_err(|err| ConfigError {
diff --git a/docs/release-notes.md b/docs/release-notes.md
index 800d44dbb..845a9c540 100644
--- a/docs/release-notes.md
+++ b/docs/release-notes.md
@@ -20,6 +20,12 @@ of `zizmor`.
 * zizmor now unconditionally emits its version number to stderr on
   startup (#1199)
 
+### Bug Fixes 🐛
+
+* Fixed a bug where `zizmor` would fail instead of analyzing single-file
+  inputs that lacked an explicit parent path component, e.g.
+  `zizmor foo.yml` instead of `zizmor ./foo.yml` (#1212)
+
 ## 1.14.2
 
 ### Bug Fixes 🐛


Test patch:
diff --git a/crates/zizmor/tests/integration/common.rs b/crates/zizmor/tests/integration/common.rs
index 10eea161c..461a18a4d 100644
--- a/crates/zizmor/tests/integration/common.rs
+++ b/crates/zizmor/tests/integration/common.rs
@@ -118,6 +118,11 @@ impl Zizmor {
         self
     }
 
+    pub fn working_dir(mut self, dir: impl Into<String>) -> Self {
+        self.cmd.current_dir(dir.into());
+        self
+    }
+
     pub fn run(mut self) -> Result<String> {
         if self.offline {
             self.cmd.arg("--offline");
diff --git a/crates/zizmor/tests/integration/e2e.rs b/crates/zizmor/tests/integration/e2e.rs
index 30788d771..20e8e38d7 100644
--- a/crates/zizmor/tests/integration/e2e.rs
+++ b/crates/zizmor/tests/integration/e2e.rs
@@ -344,3 +344,24 @@ fn warn_on_min_confidence_unknown() -> Result<()> {
     );
     Ok(())
 }
+
+/// Regression test for #1207.
+///
+/// Ensures that we correctly handle single-inputs that aren't given
+/// with an explicit parent path, e.g. `action.yml` instead of
+/// `./action.yml`.
+#[test]
+fn issue_1207() -> Result<()> {
+    insta::assert_snapshot!(
+        zizmor()
+            .expects_failure(false)
+            .output(OutputMode::Both)
+            .working_dir(input_under_test("e2e-menagerie/dummy-action-1"))
+            // Input doesn't matter, as long as it's relative without a leading
+            // `./` or other path component.
+            .input("action.yaml")
+            .run()?
+    );
+
+    Ok(())
+}
diff --git a/crates/zizmor/tests/integration/snapshots/integration__e2e__issue_1207.snap b/crates/zizmor/tests/integration/snapshots/integration__e2e__issue_1207.snap
new file mode 100644
index 000000000..00fc3e3d5
--- /dev/null
+++ b/crates/zizmor/tests/integration/snapshots/integration__e2e__issue_1207.snap
@@ -0,0 +1,12 @@
+---
+source: crates/zizmor/tests/integration/e2e.rs
+expression: "zizmor().expects_failure(false).output(OutputMode::Both).working_dir(input_under_test(\"e2e-menagerie/dummy-action-1\")).input(\"action.yaml\").run()?"
+---
+🌈 zizmor v@@VERSION@@
+ INFO zizmor::registry: skipping impostor-commit: can't run without a GitHub API token
+ INFO zizmor::registry: skipping ref-confusion: can't run without a GitHub API token
+ INFO zizmor::registry: skipping known-vulnerable-actions: can't run without a GitHub API token
+ INFO zizmor::registry: skipping stale-action-refs: can't run without a GitHub API token
+ INFO zizmor::registry: skipping ref-version-mismatch: can't run without a GitHub API token
+ INFO audit: zizmor: 🌈 completed @@INPUT@@
+No findings to report. Good job!