Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ Usage: cyclonelab <COMMAND>
Commands:
validate Checks that a file is valid JSON and conforms to the CycloneDX schema
transform Applies a declarative transformation recipe to a CycloneDX SBOM
lint Checks that a transformation YAML file is well-formed, without requiring an SBOM
suggest Suggests useful component fields missing from a CycloneDX SBOM
help Print this message or the help of the given subcommand(s)

Expand Down
34 changes: 33 additions & 1 deletion llms.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# cyclonelab

> `cyclonelab` is a CLI for generating and manipulating [CycloneDX](https://cyclonedx.org/) Software Bills of
> Materials (SBOMs). It ships three subcommands — `validate`, `transform`, `suggest` — and
> Materials (SBOMs). It ships four subcommands — `validate`, `transform`, `lint`, `suggest` — and
> supports CycloneDX spec versions **1.5**, **1.6**, and **1.7** (JSON only). This file gives an LLM (ChatGPT, Gemini,
> Claude, ...) enough detail to write correct `cyclonelab` invocations and valid `transform` YAML recipes.

Expand All @@ -13,6 +13,7 @@ cyclonelab <COMMAND>
Commands:
validate Check that a file is valid JSON and conforms to the CycloneDX schema
transform Apply a declarative YAML transformation recipe to a CycloneDX SBOM
lint Check that a transformation YAML file is well-formed, without requiring an SBOM
suggest Suggest useful component/metadata fields missing from a CycloneDX SBOM
help Print this message or the help of the given subcommand(s)
```
Expand Down Expand Up @@ -362,6 +363,37 @@ cyclonelab transform template-sbom.cdx.json recipe.yaml "dist/{$artifact_stem}-s
--variable repo=code-rhapsodie/cyclonelab --variable version=1.2.0
```

## `cyclonelab lint`

```
cyclonelab lint <TRANSFORM_FILE>
```

Statically checks a `transform` recipe YAML file for well-formedness — **no `SBOM_FILE` is read or required**, so
it can run before an SBOM even exists (e.g. as a fast CI check on a recipe change). It performs every check
`transform` does on the recipe file itself, minus anything that requires resolving a JSONPath against an actual
document:

- YAML parses and matches the recipe schema (same `file:line:column: message` error as `transform` on failure).
- No duplicate step `id`s; every `manual` step has a non-empty `description`; every `upgrade` step's
`version_target` is reachable by a bundled recipe.
- Every `target`/`source`/`paths` JSONPath is syntactically valid (a plain syntax check — it cannot know whether the
path will match anything in a real document, since none is loaded).
- Action-specific option combinations are checked exactly as `transform` checks them at run time: `add` has exactly
one of `value`/`valueFrom`, and `valueFrom`'s `file`/`generator` fields are consistent (known `format`, a
`generator: hash` has `algo: sha256` and exactly one of `path`/`url`); `merge`'s `value` is valid JSON, and a
`target[]` value is a JSON array; a `target[]`/`value[]` append form isn't combined with `when`.
- `foreach`'s reserved iteration variable names (`artifact_name`, `artifact_stem`, `artifact_path`) don't collide
with a declared `variables:` entry.
- Every `{$name}` placeholder used in a step resolves to either a declared `variables:` entry or (when `foreach` is
set) a reserved iteration variable name — an unresolvable one is almost always a typo, since `{$var}` templating
silently leaves an unknown placeholder untouched instead of failing at `transform` run time, so `lint` turns that
silent no-op into a hard error. A declared variable that no step ever references is only a warning (not fatal),
printed to stdout.

On success: prints `'<file>' looks valid.` (after any unused-variable warnings) and exits 0. On any of the above
failing: prints an error and exits non-zero, same as `transform` would once it got that far.

## CycloneDX specifics

- **Supported `specVersion` values**: `1.5`, `1.6`, `1.7` — JSON format only. The matching JSON Schema is bundled and
Expand Down
5 changes: 4 additions & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use anyhow::Result;
use clap::{Parser, Subcommand};

use crate::commands::{suggest, transform, validate};
use crate::commands::{lint, suggest, transform, validate};
use crate::version;

#[derive(Debug, Parser)]
Expand All @@ -21,6 +21,8 @@ enum Commands {
Validate(validate::ValidateArgs),
/// Applies a declarative transformation recipe to a CycloneDX SBOM.
Transform(transform::TransformArgs),
/// Checks that a transformation YAML file is well-formed, without requiring an SBOM.
Lint(lint::LintArgs),
/// Suggests useful component fields missing from a CycloneDX SBOM.
Suggest(suggest::SuggestArgs),
}
Expand All @@ -30,6 +32,7 @@ impl Cli {
match &self.command {
Commands::Validate(args) => validate::run(args),
Commands::Transform(args) => transform::run(args),
Commands::Lint(args) => lint::run(args),
Commands::Suggest(args) => suggest::run(args),
}
}
Expand Down
112 changes: 112 additions & 0 deletions src/commands/lint.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
//! `lint` subcommand: statically checks a transformation YAML file for
//! well-formedness, without requiring an SBOM (see
//! `doc/transform/README.md`).

use std::collections::HashSet;
use std::path::PathBuf;

use anyhow::{Result, bail};
use clap::Args;

use crate::commands::transform;
use crate::transform_actions;

#[derive(Debug, Args)]
pub struct LintArgs {
/// YAML file describing the transformation steps.
transform_file: PathBuf,
}

pub fn run(args: &LintArgs) -> Result<()> {
if !args.transform_file.is_file() {
bail!(
"Unable to find transformation file '{}'",
args.transform_file.display()
);
}

let transform_file = transform::load_transform_file(&args.transform_file)?;

transform_actions::validate_steps(&transform_file.steps)?;
transform_actions::lint_steps(&transform_file.steps)?;
transform::check_foreach_variable_conflicts(
transform_file.foreach.as_ref(),
&transform_file.variables,
)?;
check_variables(&transform_file)?;

println!("'{}' looks valid.", args.transform_file.display());
Ok(())
}

/// Cross-checks declared variables against every `{$name}` placeholder used
/// across the file's steps: a name used but never declared (almost always a
/// typo, since `util::template::render` leaves an unknown placeholder
/// untouched instead of failing) is a hard error; a declared variable never
/// referenced is only a warning.
fn check_variables(transform_file: &transform::TransformFile) -> Result<()> {
let mut declared: HashSet<String> = transform_file.variables.keys().cloned().collect();
if transform_file.foreach.is_some() {
declared.extend(transform::FOREACH_VAR_NAMES.iter().map(|s| s.to_string()));
}

let serialized = serde_json::to_string(&transform_file.steps)?;
let used = referenced_variable_names(&serialized);

for name in &used {
if !declared.contains(name.as_str()) {
bail!("variable '{name}' is used in a step but never declared in 'variables:'");
}
}

for name in transform_file.variables.keys() {
if !used.contains(name.as_str()) {
println!("warning: variable '{name}' is declared but never used");
}
}

Ok(())
}

/// Every `{$name}` placeholder found in `text` (e.g. every step,
/// JSON-serialized), regardless of whether it resolves to a declared
/// variable.
fn referenced_variable_names(text: &str) -> HashSet<String> {
let mut names = HashSet::new();
let mut rest = text;
while let Some(start) = rest.find("{$") {
let after = &rest[start + 2..];
let Some(end) = after.find('}') else {
break;
};
let candidate = &after[..end];
if !candidate.is_empty()
&& candidate
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_')
{
names.insert(candidate.to_string());
}
rest = &after[end + 1..];
}
names
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn referenced_variable_names_finds_every_placeholder() {
let names = referenced_variable_names(r#"{"target":"{$repo}/{$version}"}"#);
assert_eq!(
names,
HashSet::from(["repo".to_string(), "version".to_string()])
);
}

#[test]
fn referenced_variable_names_ignores_a_dollar_not_forming_a_placeholder() {
assert!(referenced_variable_names("no placeholder here").is_empty());
}
}
1 change: 1 addition & 0 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
//! function, then register it in `crate::cli::Commands` and in
//! `crate::cli::Cli::run`.

pub mod lint;
pub mod suggest;
pub mod transform;
pub mod validate;
25 changes: 15 additions & 10 deletions src/commands/transform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ use crate::util::template::{matches_single_wildcard, render};

/// Names of the ambient variables `foreach` injects for each matched file
/// (see `doc/transform/foreach.md` §"Variables d'itération"): reserved, so a
/// declared `variables:` entry cannot reuse one of them.
const FOREACH_VAR_NAMES: [&str; 3] = ["artifact_name", "artifact_stem", "artifact_path"];
/// declared `variables:` entry cannot reuse one of them. Also used by `lint`
/// (see `commands::lint`), which needs the same reserved names to check
/// variable usage without running `foreach` for real.
pub(crate) const FOREACH_VAR_NAMES: [&str; 3] = ["artifact_name", "artifact_stem", "artifact_path"];

#[derive(Debug, Args)]
pub struct TransformArgs {
Expand All @@ -37,22 +39,25 @@ pub struct TransformArgs {
variables: Vec<String>,
}

/// Also used, read-only, by `lint` (see `commands::lint`), which needs the
/// same deserialization and static checks `transform` runs before it ever
/// looks at an SBOM.
#[derive(Debug, Deserialize)]
struct TransformFile {
pub(crate) struct TransformFile {
#[serde(default)]
from: Option<String>,
#[serde(default)]
#[allow(dead_code)]
to: Option<String>,
#[serde(default)]
variables: HashMap<String, VariableDecl>,
pub(crate) variables: HashMap<String, VariableDecl>,
#[serde(default)]
foreach: Option<ForeachDecl>,
steps: Vec<Step>,
pub(crate) foreach: Option<ForeachDecl>,
pub(crate) steps: Vec<Step>,
}

#[derive(Debug, Deserialize)]
struct VariableDecl {
pub(crate) struct VariableDecl {
#[serde(default)]
env: Option<String>,
#[serde(default)]
Expand All @@ -65,7 +70,7 @@ struct VariableDecl {
/// `steps` pipeline once per file found in `dir` matching `pattern`, instead
/// of running it once on a fixed `OUTPUT_FILE`.
#[derive(Debug, Deserialize)]
struct ForeachDecl {
pub(crate) struct ForeachDecl {
dir: PathBuf,
pattern: String,
}
Expand Down Expand Up @@ -285,7 +290,7 @@ fn run_foreach(
/// iteration variable names (see `doc/transform/foreach.md` §"Variables
/// d'itération") — checked once at load time, regardless of how many (if
/// any) files `foreach` will later match.
fn check_foreach_variable_conflicts(
pub(crate) fn check_foreach_variable_conflicts(
foreach: Option<&ForeachDecl>,
declared: &HashMap<String, VariableDecl>,
) -> Result<()> {
Expand Down Expand Up @@ -357,7 +362,7 @@ fn load_and_validate_sbom(sbom_file: &std::path::Path) -> Result<Value> {
Ok(document)
}

fn load_transform_file(transform_file: &std::path::Path) -> Result<TransformFile> {
pub(crate) fn load_transform_file(transform_file: &std::path::Path) -> Result<TransformFile> {
let content = fs::read_to_string(transform_file)
.with_context(|| format!("Unable to read '{}'", transform_file.display()))?;
yaml_serde::from_str(&content).map_err(|err| {
Expand Down
Loading
Loading