Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
71 changes: 71 additions & 0 deletions .github/workflows/cf11g-context-proof.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
name: cf11g-context-proof

on:
pull_request:
paths:
- .github/workflows/cf11g-context-proof.yml
- Cargo.toml
- Cargo.lock
- crates/commandf-pkg/**
- crates/commandf-cli/**
- specs/012-cf-11g-ecosystem-context-graph/**
push:
branches:
- impl/cf11g-context-cli
paths:
- .github/workflows/cf11g-context-proof.yml
- Cargo.toml
- Cargo.lock
- crates/commandf-pkg/**
- crates/commandf-cli/**
- specs/012-cf-11g-ecosystem-context-graph/**
workflow_dispatch:

permissions:
contents: read

env:
CF11G_PROOF_CONTAINER: docker.io/library/rust@sha256:9146b0f62e1939989aa96fc8d89699a43c5635bf212819235a773e1a9e71a98f

jobs:
deterministic-context-cli:
runs-on: ubuntu-24.04
container:
# Docker Official Image rust:1.97.1-trixie, pinned to the linux/amd64 manifest.
image: docker.io/library/rust@sha256:9146b0f62e1939989aa96fc8d89699a43c5635bf212819235a773e1a9e71a98f
timeout-minutes: 15
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 / Node 24
with:
persist-credentials: false

- name: Assert pinned execution toolchain
run: |
set -euo pipefail
rustc --version --verbose
cargo --version
test "$(rustc --version | awk '{print $2}')" = "1.97.1"

- name: Prove byte-identical commandf context output
run: |
set -euo pipefail
cargo test --locked -p commandf --test context_determinism_proof -- --nocapture \
| tee /tmp/cf11g-context-proof.log
grep -oE 'CF11G_CONTEXT_SHA256=[0-9a-f]{64}' /tmp/cf11g-context-proof.log \
| tail -n 1 \
| tee /tmp/cf11g-context.sha256
test -s /tmp/cf11g-context.sha256

- name: Assert repository remains clean
run: |
set -euo pipefail
status="$(git -c safe.directory="$GITHUB_WORKSPACE" status --porcelain)"
test -z "$status"

- name: Upload context determinism evidence
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: cf11g-context-proof
path: /tmp/cf11g-context.sha256
if-no-files-found: error
retention-days: 3
22 changes: 21 additions & 1 deletion crates/commandf-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use std::process::{self, ExitCode};

use clap::{Parser, Subcommand, ValueEnum};
use commandf_pkg::{
build_source_mapped_check_report, build_terminology_diff_report,
build_context_graph, build_source_mapped_check_report, build_terminology_diff_report,
check_report_to_github_annotations_bytes, check_report_to_sarif_bytes,
classify_structural_diff, diff_package_archives, evaluate_compatibility_policy,
inspect_package, source_mapped_check_report_to_github_annotations_bytes, CheckDirection,
Expand Down Expand Up @@ -48,6 +48,14 @@ enum Command {
#[arg(long, value_enum, default_value = "json")]
format: OutputFormat,
},
Context {
#[arg(long, default_value = "commandf.lock")]
lock: PathBuf,
#[arg(long, default_value = ".commandf/cache")]
cache: PathBuf,
#[arg(long, value_enum, default_value = "json")]
format: OutputFormat,
},
Diff {
package: String,
#[arg(long)]
Expand Down Expand Up @@ -319,6 +327,18 @@ fn run(cli: Cli) -> Result<ExitCode, Box<dyn std::error::Error>> {
OutputFormat::Json => io::stdout().write_all(&inspection.to_json_bytes()?)?,
}
}
Command::Context {
lock,
cache,
format,
} => {
let lockfile = Lockfile::from_slice(&fs::read(&lock)?)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

2. Lock parse failures untested 📘 Rule violation ▣ Testability

The new context handler can fail while reading or parsing --lock, but no context CLI test
supplies a missing lock or malformed lock bytes. Consequently, these input failure branches and
their user-visible diagnostics are not covered.
Agent Prompt
## Issue description
The new `commandf context` lock read and parse failure branches have no command-level automated coverage.

## Issue Context
Existing context tests serialize valid lock objects, including the schema-v1 migration case; that does not execute `fs::read` failure or `Lockfile::from_slice` malformed-input failure. Add deterministic CLI tests that assert exit status, empty stdout, and stable stderr behavior for each branch.

## Fix Focus Areas
- crates/commandf-cli/src/main.rs[335-335]
- crates/commandf-cli/tests/context_behavior.rs[99-117]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

4. Lockfile read is unbounded 🐞 Bug ☼ Reliability

The new context command fully allocates the user-supplied lockfile with fs::read and then
deserializes unconstrained vectors, maps, and strings. A large synthetic lockfile can exhaust memory
and trigger substantial validation CPU before cache processing begins.
Agent Prompt
## Issue description
`commandf context` reads and parses lockfiles without a byte limit, allowing oversized input to consume unbounded memory and validation CPU.

## Issue Context
The CLI already provides `read_bounded_file`; introduce a deliberate maximum lockfile size and use that helper before `Lockfile::from_slice`. Add boundary tests for the exact limit and limit plus one.

## Fix Focus Areas
- crates/commandf-cli/src/main.rs[21-23]
- crates/commandf-cli/src/main.rs[335-335]
- crates/commandf-cli/src/main.rs[520-531]
- crates/commandf-cli/tests/context_behavior.rs[99-177]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

let cache = PackageCache::new(cache);
let report = build_context_graph(&lockfile, &cache)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

3. Duplicate filenames misattribute edges 🐞 Bug ≡ Correctness

build_context_graph indexes inspected resources only by filename, so duplicate TAR entries
overwrite each other while references are still extracted from every scanned entry. A package
containing two different package/Foo.json entries can therefore emit references from one resource
under the other resource's artifact hash, corrupting graph evidence.
Agent Prompt
## Issue description
The newly exposed context graph path can associate references with the wrong artifact when an archive contains duplicate normalized resource filenames.

## Issue Context
Resource scanning preserves duplicate filenames, but graph construction stores inspected resources in a filename-keyed map where later entries overwrite earlier entries. Fail closed on duplicate normalized resource paths, or preserve a one-to-one identity that includes the resource digest.

## Fix Focus Areas
- crates/commandf-cli/src/main.rs[335-339]
- crates/commandf-pkg/src/artifact_scan.rs[61-92]
- crates/commandf-pkg/src/context.rs[92-143]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

match format {
OutputFormat::Json => io::stdout().write_all(&report.to_json_bytes()?)?,
}
}
Command::Diff {
package,
before_lock,
Expand Down
259 changes: 259 additions & 0 deletions crates/commandf-cli/tests/context_behavior.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::time::{SystemTime, UNIX_EPOCH};

use commandf_pkg::{LockedPackage, Lockfile, PackageCache, ResolvedDependency};

const PARENT_A_ARCHIVE: &[u8] = include_bytes!("fixtures/parent-a.tgz");
const PARENT_B_ARCHIVE: &[u8] = include_bytes!("fixtures/parent-b.tgz");
const SHARED_V1_ARCHIVE: &[u8] = include_bytes!("fixtures/shared-v1.tgz");
const SHARED_V2_ARCHIVE: &[u8] = include_bytes!("fixtures/shared-v2.tgz");
const MALFORMED_ARCHIVE: &[u8] = include_bytes!("fixtures/malformed.tgz");

fn commandf() -> Command {
Command::new(env!("CARGO_BIN_EXE_commandf"))
}

fn unique_temp_dir(label: &str) -> PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock after epoch")
.as_nanos();
std::env::temp_dir().join(format!(
"commandf-context-{label}-{}-{nonce}",
std::process::id()
))
}

fn run_context(lock: &Path, cache: &Path) -> Output {
commandf()
.args([
"context",
"--lock",
lock.to_str().expect("UTF-8 lock path"),
"--cache",
cache.to_str().expect("UTF-8 cache path"),
"--format",
"json",
])
.env("HTTP_PROXY", "http://127.0.0.1:9")
.env("HTTPS_PROXY", "http://127.0.0.1:9")
.env("NO_PROXY", "")
.output()
.expect("commandf context must execute")
}

#[test]
fn context_help_exposes_offline_inputs() {
let output = commandf()
.args(["context", "--help"])
.output()
.expect("commandf context help must execute");
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).expect("UTF-8 help");
for flag in ["--lock", "--cache", "--format"] {
assert!(stdout.contains(flag), "missing {flag}");
}
}

#[test]
fn context_emits_byte_identical_multi_version_graph_evidence() {
let root = unique_temp_dir("success");
let (lock, cache) = write_context_state(&root);

let first = run_context(&lock, &cache);
let second = run_context(&lock, &cache);
assert_success(&first);
assert_success(&second);
assert_eq!(first.stdout, second.stdout);

let json = String::from_utf8(first.stdout).expect("UTF-8 context JSON");
for evidence in [
"\"lock_schema\": 2",
"\"name\": \"acme.shared\"",
"\"version\": \"1.0.0\"",
"\"version\": \"2.0.0\"",
"\"id\": \"extension\"",
"\"resource_type\": \"Patient\"",
"\"resolution\": \"resolved\"",
"\"resolution\": \"external\"",
"\"resolution\": \"ambiguous\"",
"\"relation\": \"structure_base_definition\"",
"\"relation\": \"structure_type_profile\"",
"\"relation\": \"structure_type_target_profile\"",
"\"relation\": \"structure_binding_value_set\"",
"\"relation\": \"value_set_include_system\"",
"\"relation\": \"value_set_include_value_set\"",
"\"relation\": \"value_set_exclude_system\"",
"\"relation\": \"code_system_supplements\"",
"\"unsupported_source_resource_types\": [\n \"Patient\"\n ]",
] {
assert!(json.contains(evidence), "missing evidence: {evidence}");
}

let _ = fs::remove_dir_all(root);
}

#[test]
fn context_rejects_schema_v1_with_stable_migration_diagnostic() {
let root = unique_temp_dir("schema-v1");
let lock = root.join("commandf.lock");
let cache = root.join("cache");
fs::create_dir_all(&cache).unwrap();
fs::write(
&lock,
Lockfile::new(Vec::new(), Vec::new()).to_bytes().unwrap(),
)
.unwrap();

let output = run_context(&lock, &cache);
assert_eq!(output.status.code(), Some(1));
assert!(output.stdout.is_empty());
assert!(String::from_utf8_lossy(&output.stderr)
.contains("commandf context requires commandf.lock schema 2; found schema 1"));
let _ = fs::remove_dir_all(root);
}

#[test]
fn context_fails_closed_on_missing_corrupt_and_malformed_inputs() {
let missing_root = unique_temp_dir("missing");
fs::create_dir_all(&missing_root).unwrap();
let missing_lock = missing_root.join("commandf.lock");
let missing_cache = missing_root.join("cache");
let missing = Lockfile::new_v2(
vec!["acme.root@1.0.0".to_owned()],
vec![locked_package(
"acme.root",
"1.0.0",
&"a".repeat(64),
BTreeMap::new(),
)],
vec![],
);
fs::write(&missing_lock, missing.to_bytes().unwrap()).unwrap();
let output = run_context(&missing_lock, &missing_cache);
assert_eq!(output.status.code(), Some(1));
assert!(output.stdout.is_empty());
Comment on lines +137 to +138

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. Cache failures lack diagnostics 📘 Rule violation ▣ Testability

The missing-archive and corrupt-digest CLI cases assert only exit code and empty stdout, without
checking the emitted error type or message. This allows these distinct failure branches to regress
to an incorrect or misleading diagnostic unnoticed.
Agent Prompt
## Issue description
The `commandf context` tests trigger missing-archive and corrupt-digest failures but do not verify their stderr diagnostics.

## Issue Context
PR Compliance 2717396 requires each distinct handler failure branch to assert the resulting status and error message or state. Add stable assertions for both cache failures while retaining the existing exit-code and empty-stdout checks.

## Fix Focus Areas
- crates/commandf-cli/tests/context_behavior.rs[136-152]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

let _ = fs::remove_dir_all(missing_root);

let corrupt_root = unique_temp_dir("corrupt");
let (corrupt_lock, corrupt_cache) = write_context_state(&corrupt_root);
let lockfile = Lockfile::from_slice(&fs::read(&corrupt_lock).unwrap()).unwrap();
let digest = &lockfile.packages[0].sha256;
fs::write(
corrupt_cache.join("sha256").join(format!("{digest}.tgz")),
b"corrupted",
)
.unwrap();
let output = run_context(&corrupt_lock, &corrupt_cache);
assert_eq!(output.status.code(), Some(1));
assert!(output.stdout.is_empty());
let _ = fs::remove_dir_all(corrupt_root);

let malformed_root = unique_temp_dir("malformed");
let malformed_cache = malformed_root.join("cache");
let malformed_lock = malformed_root.join("commandf.lock");
fs::create_dir_all(&malformed_root).unwrap();
let cache = PackageCache::new(&malformed_cache);
let digest = cache.put(MALFORMED_ARCHIVE).unwrap();
let lock = Lockfile::new_v2(
vec!["acme.bad@1.0.0".to_owned()],
vec![locked_package(
"acme.bad",
"1.0.0",
&digest,
BTreeMap::new(),
)],
vec![],
);
fs::write(&malformed_lock, lock.to_bytes().unwrap()).unwrap();
let output = run_context(&malformed_lock, &malformed_cache);
assert_eq!(output.status.code(), Some(1));
assert!(output.stdout.is_empty());
assert!(String::from_utf8_lossy(&output.stderr).contains("must be an array"));
let _ = fs::remove_dir_all(malformed_root);
}

fn write_context_state(root: &Path) -> (PathBuf, PathBuf) {
fs::create_dir_all(root).unwrap();
let cache_path = root.join("cache");
let lock_path = root.join("commandf.lock");
let cache = PackageCache::new(&cache_path);

let parent_a_sha = cache.put(PARENT_A_ARCHIVE).unwrap();
let parent_b_sha = cache.put(PARENT_B_ARCHIVE).unwrap();
let shared_v1_sha = cache.put(SHARED_V1_ARCHIVE).unwrap();
let shared_v2_sha = cache.put(SHARED_V2_ARCHIVE).unwrap();

let mut parent_a_dependencies = BTreeMap::new();
parent_a_dependencies.insert("acme.shared".to_owned(), "1.0.0".to_owned());
let mut parent_b_dependencies = BTreeMap::new();
parent_b_dependencies.insert("acme.shared".to_owned(), "2.0.0".to_owned());

let lock = Lockfile::new_v2(
vec![
"acme.parentb@1.0.0".to_owned(),
"acme.parenta@1.0.0".to_owned(),
],
vec![
locked_package(
"acme.parenta",
"1.0.0",
&parent_a_sha,
parent_a_dependencies,
),
locked_package(
"acme.parentb",
"1.0.0",
&parent_b_sha,
parent_b_dependencies,
),
locked_package("acme.shared", "1.0.0", &shared_v1_sha, BTreeMap::new()),
locked_package("acme.shared", "2.0.0", &shared_v2_sha, BTreeMap::new()),
],
vec![
ResolvedDependency {
from_name: "acme.parenta".to_owned(),
from_version: "1.0.0".to_owned(),
to_name: "acme.shared".to_owned(),
to_version: "1.0.0".to_owned(),
declared_constraint: "1.0.0".to_owned(),
},
ResolvedDependency {
from_name: "acme.parentb".to_owned(),
from_version: "1.0.0".to_owned(),
to_name: "acme.shared".to_owned(),
to_version: "2.0.0".to_owned(),
declared_constraint: "2.0.0".to_owned(),
},
],
);
fs::write(&lock_path, lock.to_bytes().unwrap()).unwrap();
(lock_path, cache_path)
}

fn assert_success(output: &Output) {
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(output.stderr.is_empty());
}

fn locked_package(
name: &str,
version: &str,
sha256: &str,
dependencies: BTreeMap<String, String>,
) -> LockedPackage {
LockedPackage {
name: name.to_owned(),
version: version.to_owned(),
sha256: sha256.to_owned(),
source: "synthetic-context-test".to_owned(),
dependencies,
}
}
Loading
Loading