Skip to content
Closed
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
6 changes: 6 additions & 0 deletions .fusa-reqs.json
Original file line number Diff line number Diff line change
Expand Up @@ -2454,6 +2454,12 @@
"text": "gateContentQuality shall scan an artifact's qualitative fields for FUSA-STUB001/002 findings, print them, and return ExitGateFail when a placeholder match exists or when an unsuppressed blanket-fallback match exists and strict gating is requested.",
"standard": "x-FuSa spec section 1.6.1"
},
{
"id": "REQ-CLI-HELPERS005",
"title": "carryForwardAttestation loads a prior output file's attestation",
"text": "carryForwardAttestation shall read the attestation field from the prior saved copy of an artifact-producing command's output file and return it, or nil if the file is absent, unreadable, malformed, or carries no attestation, so each fmea/tara/safety-case/sas command can carry a reviewed attestation forward onto its freshly-rebuilt output per x-FuSa spec section 1.6.2.",
"standard": "x-FuSa spec section 1.6.2"
},
{
"id": "REQ-FMEA007",
"title": "FMEA summary block with coverage metrics",
Expand Down
70 changes: 45 additions & 25 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,51 @@ Dates reference the merged commit timestamp.

## [Unreleased]

## v0.44.0 — 2026-07-28 (x-FuSa spec §1.6.2 attestation carry-forward MUST)

### Fixed
- **§1.6.2 attestation is no longer silently wiped on every regeneration**
of `fmea.json`/`tara.json`/`safety-case.json`/`sas.json` (x-FuSa spec
v1.15.0 §1.6.2, now a MUST). `gofusa fmea`/`tara`/`safety-case`/`sas`
each unconditionally overwrote their output with a brand-new report
built from scratch, none of which loaded the existing file first or
copied forward its `attestation` field — a hand-added, valid, reviewed
attestation was discarded the moment the command was re-run, even when
nothing about the artifact's substantive content had changed
(go-FuSa#57). Each command now calls the new `carryForwardAttestation`
helper (`cmd/gofusa/helpers.go`) to load the prior saved output file's
`attestation` field and carry it forward onto the freshly-built result
before writing. Staleness continues to fall out automatically: a
carried-forward `contentHash` that no longer matches the freshly
computed content hash means `stubcheck.AttestationSuppresses` treats it
as not currently suppressing (via `fusa.AttestationValid`), never that
it vanished outright.

## v0.42.0 — 2026-07-28 (hara: risk.asil cross-validation + canonical standard id)

### Fixed
- **HARA008: `risk.asil` is now cross-validated against `DetermineASIL(S,E,C)`**
(x-FuSa spec §1.2.5 MUST — ASIL determination). Previously a hazard's
stored `risk.asil` was accepted verbatim: `DetermineASIL` was only ever
used as a *fallback* for an empty value, so a hand-edited or
copy-pasted hazard could claim any ASIL regardless of its own S/E/C
inputs, with zero findings/gaps from either `gofusa hara show` or
`check`. The new `hara.ValidateASIL` (wrapped by the new engine rule
`HARA008`, and folded into `hara.Validate`'s own gap list so `hara show`
surfaces it directly) flags a hazard whose declared `risk.asil` disagrees
with the ISO 26262-3:2018 Table 4 value for its own severity/exposure/
controllability — skipping hazards with an incomplete S/E/C rating
(HARA002's job) or no `risk.asil` set yet.
- **`standard` now uses the x-FuSa spec §2.4.1 canonical lowercase id**
(`iso26262`, not `"ISO 26262"`) in `.fusa-hara.json`: `hara init`'s
default `--standard` flag value changed from `"ISO 26262"` to
`"iso26262"`, the repo's own checked-in `.fusa-hara.json` was
normalised, and `hara.Load` now transparently normalises a legacy
display-string value (`"ISO 26262"`, `"IEC 61508"`, …) onto its
canonical id for backward compatibility with hand-authored files
predating this convention — an unrecognised id is still passed through
verbatim, never rejected.

## v0.41.0 — 2026-07-28 (tara: closed impact/risk enums per x-FuSa spec v1.14.1)

### Fixed
Expand Down Expand Up @@ -55,31 +100,6 @@ Dates reference the merged commit timestamp.
continues to be suppressed only by a valid §1.6.2 attestation, never by
disposition.

## v0.42.0 — 2026-07-28 (hara: risk.asil cross-validation + canonical standard id)

### Fixed
- **HARA008: `risk.asil` is now cross-validated against `DetermineASIL(S,E,C)`**
(x-FuSa spec §1.2.5 MUST — ASIL determination). Previously a hazard's
stored `risk.asil` was accepted verbatim: `DetermineASIL` was only ever
used as a *fallback* for an empty value, so a hand-edited or
copy-pasted hazard could claim any ASIL regardless of its own S/E/C
inputs, with zero findings/gaps from either `gofusa hara show` or
`check`. The new `hara.ValidateASIL` (wrapped by the new engine rule
`HARA008`, and folded into `hara.Validate`'s own gap list so `hara show`
surfaces it directly) flags a hazard whose declared `risk.asil` disagrees
with the ISO 26262-3:2018 Table 4 value for its own severity/exposure/
controllability — skipping hazards with an incomplete S/E/C rating
(HARA002's job) or no `risk.asil` set yet.
- **`standard` now uses the x-FuSa spec §2.4.1 canonical lowercase id**
(`iso26262`, not `"ISO 26262"`) in `.fusa-hara.json`: `hara init`'s
default `--standard` flag value changed from `"ISO 26262"` to
`"iso26262"`, the repo's own checked-in `.fusa-hara.json` was
normalised, and `hara.Load` now transparently normalises a legacy
display-string value (`"ISO 26262"`, `"IEC 61508"`, …) onto its
canonical id for backward compatibility with hand-authored files
predating this convention — an unrecognised id is still passed through
verbatim, never rejected.

## v0.36.0 — 2026-07-28 (x-FuSa spec v1.13.0/v1.14.0 — evidence-artifact schema conformance + content-quality baseline)

### Added
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ docker build -t go-fusa .
docker run --rm -v "$(pwd)":/project go-fusa check
```

Published tags: `latest`, `0.42`, `0.42.0` (and matching semver for every release).
Published tags: `latest`, `0.44`, `0.44.0` (and matching semver for every release).

## Standards coverage

Expand Down
265 changes: 265 additions & 0 deletions cmd/gofusa/cmd_attestation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
package main

// cmd_attestation_test.go covers x-FuSa spec §1.6.2's carry-forward MUST
// (spec v1.15.0): before an artifact-producing command rebuilds its output,
// it must load any existing attestation from the prior saved output file and
// carry it forward onto the freshly-built result, rather than discarding it
// (go-FuSa#57). Each artifact command (fmea/tara/safety-case/sas) is run
// twice: the first run produces a fresh artifact with no attestation; a
// "reviewed" attestation with a matching contentHash is then hand-added; the
// second run must preserve that attestation rather than silently wiping it.

import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"testing"

fusa "github.com/SoundMatt/go-FuSa"
)

//fusa:test REQ-CLI-HELPERS005
func TestCarryForwardAttestation_AbsentFile(t *testing.T) {
if att := carryForwardAttestation(filepath.Join(t.TempDir(), "does-not-exist.json")); att != nil {
t.Errorf("expected nil for an absent file, got %+v", att)
}
}

//fusa:test REQ-CLI-HELPERS005
func TestCarryForwardAttestation_MalformedFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "bad.json")
if err := os.WriteFile(path, []byte("{not valid json"), 0o640); err != nil {
t.Fatal(err)
}
if att := carryForwardAttestation(path); att != nil {
t.Errorf("expected nil for a malformed file, got %+v", att)
}
}

//fusa:test REQ-CLI-HELPERS005
func TestCarryForwardAttestation_NoAttestationField(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "no-att.json")
if err := os.WriteFile(path, []byte(`{"entries":[]}`), 0o640); err != nil {
t.Fatal(err)
}
if att := carryForwardAttestation(path); att != nil {
t.Errorf("expected nil when no attestation key is present, got %+v", att)
}
}

//fusa:test REQ-CLI-HELPERS005
func TestCarryForwardAttestation_CarriesReviewedStatus(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "with-att.json")
body := `{"entries":[],"attestation":{"status":"reviewed","implementationAuthor":"auto","independentReviewer":"Jane Doe","contentHash":"sha256:deadbeef"}}`
if err := os.WriteFile(path, []byte(body), 0o640); err != nil {
t.Fatal(err)
}
att := carryForwardAttestation(path)
if att == nil {
t.Fatal("expected a non-nil attestation")
}
if att.Status != fusa.StatusReviewed || att.IndependentReviewer != "Jane Doe" {
t.Errorf("unexpected attestation: %+v", att)
}
}

//fusa:test REQ-CLI013
//fusa:test REQ-STUB012
func TestRunFmea_CarriesForwardAttestation(t *testing.T) {
dir := t.TempDir()
writeSafetyFuncSource(t, dir)

var out1, err1 bytes.Buffer
if code := runFmea([]string{"--dir", dir}, &out1, &err1); code != fusa.ExitOK {
t.Fatalf("first run: exit %d, stderr: %s", code, err1.String())
}

fmeaPath := filepath.Join(dir, "fmea.json")
injectReviewedAttestation(t, fmeaPath, "entries")

var out2, err2 bytes.Buffer
if code := runFmea([]string{"--dir", dir}, &out2, &err2); code != fusa.ExitOK {
t.Fatalf("second run: exit %d, stderr: %s", code, err2.String())
}
assertAttestationPreserved(t, fmeaPath)
}

//fusa:test REQ-CLI019
//fusa:test REQ-STUB012
func TestRunTara_CarriesForwardAttestation(t *testing.T) {
dir := t.TempDir()

var out1, err1 bytes.Buffer
if code := runTara([]string{"--dir", dir}, &out1, &err1); code != fusa.ExitOK {
t.Fatalf("first run: exit %d, stderr: %s", code, err1.String())
}

taraPath := filepath.Join(dir, "tara.json")
injectReviewedAttestation(t, taraPath, "threats")

var out2, err2 bytes.Buffer
if code := runTara([]string{"--dir", dir}, &out2, &err2); code != fusa.ExitOK {
t.Fatalf("second run: exit %d, stderr: %s", code, err2.String())
}
assertAttestationPreserved(t, taraPath)
}

//fusa:test REQ-CLI012
//fusa:test REQ-STUB012
func TestRunSafetyCase_CarriesForwardAttestation(t *testing.T) {
dir := t.TempDir()

var out1, err1 bytes.Buffer
if code := runSafetyCase([]string{"--dir", dir}, &out1, &err1); code != fusa.ExitOK {
t.Fatalf("first run: exit %d, stderr: %s", code, err1.String())
}

scPath := filepath.Join(dir, "safety-case.json")
injectReviewedAttestationSC(t, scPath)

var out2, err2 bytes.Buffer
if code := runSafetyCase([]string{"--dir", dir}, &out2, &err2); code != fusa.ExitOK {
t.Fatalf("second run: exit %d, stderr: %s", code, err2.String())
}
assertAttestationPreserved(t, scPath)
}

//fusa:test REQ-CLI-SAS001
//fusa:test REQ-STUB012
func TestRunSas_CarriesForwardAttestation(t *testing.T) {
dir := t.TempDir()
sasJSONPath := filepath.Join(dir, "sas.json")

var out1, err1 bytes.Buffer
// --format markdown (the default) writes the primary output to
// --output and the json companion alongside it — exercise that path,
// since it's the common case (`gofusa sas` with no flags).
outFile := filepath.Join(dir, "sas.md")
if code := runSas([]string{"--dir", dir, "--output", outFile}, &out1, &err1); code != fusa.ExitGateFail && code != fusa.ExitOK {
t.Fatalf("first run: unexpected exit %d, stderr: %s", code, err1.String())
}
if _, err := os.Stat(sasJSONPath); err != nil {
t.Fatalf("sas.json companion not written: %v", err)
}

injectReviewedAttestation(t, sasJSONPath, "deviations")

var out2, err2 bytes.Buffer
runSas([]string{"--dir", dir, "--output", outFile}, &out2, &err2)
assertAttestationPreserved(t, sasJSONPath)
}

// ─── shared test helpers ────────────────────────────────────────────────────

func writeSafetyFuncSource(t *testing.T, dir string) {
t.Helper()
src := "package main\n\n//fusa:req REQ-001\nfunc SafetyFunc() error { return nil }\n"
if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte(src), 0o644); err != nil {
t.Fatal(err)
}
}

// injectReviewedAttestation reads the JSON artifact at path, computes the
// canonical content hash over its contentKey field (e.g. "entries",
// "threats", "deviations" — whichever field the command's own
// gateContentQuality call hashes), and rewrites the file with a valid
// "reviewed" attestation whose contentHash matches that field's current
// content, so a second run's regenerated content — if unchanged — carries a
// *currently-valid* (not stale) attestation forward.
func injectReviewedAttestation(t *testing.T, path, contentKey string) {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
var doc map[string]interface{}
if unmarshalErr := json.Unmarshal(data, &doc); unmarshalErr != nil {
t.Fatalf("unmarshal %s: %v", path, unmarshalErr)
}
hash, err := fusa.AttestationContentHash(doc[contentKey])
if err != nil {
t.Fatalf("AttestationContentHash: %v", err)
}
doc["attestation"] = fusa.Attestation{
Status: fusa.StatusReviewed,
ImplementationAuthor: "auto",
IndependentReviewer: "Jane Doe <jane@example.com>",
ReviewedAt: fusa.NowRFC3339(),
ContentHash: hash,
}
out, err := json.MarshalIndent(doc, "", " ")
if err != nil {
t.Fatalf("marshal %s: %v", path, err)
}
if err := os.WriteFile(path, out, 0o640); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}

// injectReviewedAttestationSC is injectReviewedAttestation specialised for
// safety-case.json, whose content hash is computed over {nodes, edges}
// together (matching cmd_safetycase.go's gateContentQuality call), not a
// single top-level field.
func injectReviewedAttestationSC(t *testing.T, path string) {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
var doc map[string]interface{}
if unmarshalErr := json.Unmarshal(data, &doc); unmarshalErr != nil {
t.Fatalf("unmarshal %s: %v", path, unmarshalErr)
}
content := struct {
Nodes interface{} `json:"nodes"`
Edges interface{} `json:"edges"`
}{doc["nodes"], doc["edges"]}
hash, err := fusa.AttestationContentHash(content)
if err != nil {
t.Fatalf("AttestationContentHash: %v", err)
}
doc["attestation"] = fusa.Attestation{
Status: fusa.StatusReviewed,
ImplementationAuthor: "auto",
IndependentReviewer: "Jane Doe <jane@example.com>",
ReviewedAt: fusa.NowRFC3339(),
ContentHash: hash,
}
out, err := json.MarshalIndent(doc, "", " ")
if err != nil {
t.Fatalf("marshal %s: %v", path, err)
}
if err := os.WriteFile(path, out, 0o640); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}

// assertAttestationPreserved fails the test unless path's current
// "attestation" field is present with status "reviewed" — i.e. the
// regenerating command carried it forward rather than discarding it.
func assertAttestationPreserved(t *testing.T, path string) {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
var doc struct {
Attestation *fusa.Attestation `json:"attestation"`
}
if err := json.Unmarshal(data, &doc); err != nil {
t.Fatalf("unmarshal %s: %v", path, err)
}
if doc.Attestation == nil {
t.Fatalf("%s: attestation was discarded on regeneration", path)
}
if doc.Attestation.Status != fusa.StatusReviewed {
t.Errorf("%s: attestation.status = %q, want %q", path, doc.Attestation.Status, fusa.StatusReviewed)
}
if doc.Attestation.IndependentReviewer != "Jane Doe <jane@example.com>" {
t.Errorf("%s: attestation.independentReviewer = %q, want the carried-forward reviewer", path, doc.Attestation.IndependentReviewer)
}
}
8 changes: 7 additions & 1 deletion cmd/gofusa/cmd_fmea.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,14 @@ func runFmea(args []string, stdout, stderr io.Writer) int {
return fusa.ExitRuntime
}

// Write fmea.json
// x-FuSa spec §1.6.2 MUST: carry forward any existing attestation from
// the prior saved fmea.json before overwriting it — a fresh fmea.Scan
// never has one of its own. Staleness (a content change since the
// review) falls out of gateContentQuality's own hash check below.
jsonPath := filepath.Join(outDir, fmea.FMEAFile)
report.Attestation = carryForwardAttestation(jsonPath)

// Write fmea.json
if err := writeFmea(jsonPath, report, "json"); err != nil {
fmt.Fprintf(stderr, "gofusa fmea: %v\n", err)
return fusa.ExitRuntime
Expand Down
Loading
Loading