Skip to content

Commit afa0df0

Browse files
authored
Merge pull request #56 from RI-SE/cursor/w2-eval-receipt
fix: minimal W2 eval/receipt split
2 parents de116cd + a76565f commit afa0df0

22 files changed

Lines changed: 672 additions & 73 deletions

File tree

.github/workflows/ci.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,15 @@ jobs:
3636
env:
3737
SOURCE_DATE_EPOCH: "1704067200"
3838
run: go test ./...
39+
- name: W2 unset-epoch eval stability
40+
# Must not inherit the pinned SOURCE_DATE_EPOCH from the prior step.
41+
# Proves canonical evaluation bytes stay stable when the epoch is unset.
42+
if: runner.os != 'Windows'
43+
env:
44+
SOURCE_DATE_EPOCH: ""
45+
run: |
46+
unset SOURCE_DATE_EPOCH || true
47+
go test ./internal/clock/ ./internal/ir/ ./internal/validate/ -count=1 -run 'TestRejectInvalidSourceDateEpoch|TestCanonicalEvaluationStableWithoutEpoch|TestCanonicalEvaluationStableAcrossHOME|TestValidateRejectsInvalidSourceDateEpoch|TestLegacyEvaluationAdapterRoundTrip|TestMarshalCanonicalStable|TestRFC3339ForEvidenceStableWithoutEpoch'
3948
- name: Fuzz (short)
4049
if: runner.os != 'Windows'
4150
run: |

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Unreleased (on `main`, not yet pinned — Action pin stays `@v0.5.2`)
44

5+
- **W2 eval/receipt (minimal)** — reject invalid `SOURCE_DATE_EPOCH` (no silent wall-clock fallback); split canonical `curbpack-evaluation:1` from `curbpack-run-receipt:1`; write `latest_evaluation.json` / `latest_receipt.json`; keep legacy `latest_failure.json` / `latest_result.json` via adapter; omit synthetic SLSA `buildFinishedOn`; canonical evaluation stable across HOME when epoch unset; CI leg runs unset-epoch stability tests without inheriting the pinned epoch. Explicit `as_of` binding and content-addressed digest store remain open. Pin stays `@v0.5.2`. Not certification.
6+
57
## v0.5.5
68

79
High-trust launch readiness — containment, FG closures in the advertised binary, installer version-tag validation. Action pin stayed `@v0.5.2`.

docs/launch-status.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ These are not closed by compiling binaries or by the passing regression tests.
6969

7070
| Gate | Remaining work | Source |
7171
|---|---|---|
72-
| Canonical evaluation and receipt | Separate wall-clock, agent and pathway metadata from canonical evaluation; bind complete rule/input bytes and explicit `as_of`; freeze the versioned format | [SDD W2 and W5](software-design-document.md#12-sequential-work-packages), [current payload](../internal/ir/gatefailure.go) |
72+
| Canonical evaluation and receipt | **Partial (this slice):** invalid `SOURCE_DATE_EPOCH` rejected (no silent wall-clock fallback); canonical `latest_evaluation.json` omits timestamp/agent; `latest_receipt.json` holds ops metadata; legacy `latest_failure.json` / `latest_result.json` via adapter; SLSA sidecar omits synthetic `buildFinishedOn`; canonical bytes stable across HOME when epoch unset. **Still open:** explicit pack `as_of` binding, content-addressed digest store beyond `latest_*`, TMPDIR/locale axes, full schema freeze (W5) | [SDD W2](software-design-document.md#12-sequential-work-packages), [evaluation IR](../internal/ir/evaluation.go), [eval receipt tests](../internal/validate/eval_receipt_test.go) |
7373
| Transactional persistence and hostile concurrency | Cache files are individually replaced, but three aliases are not one transaction. Descriptor-based containment and concurrent directory replacement remain outside the path-check guarantee | [cache writer](../internal/validate/cache_write.go), [path jail](../internal/pathjail/pathjail.go) |
7474
| Resource and command guarantees | Evaluator-wide byte/file/subprocess budgets, interruption recovery, command effects, and consistent typed operational errors across every command | [SDD requirements](software-design-document.md#2-constitutional-invariants), [command implementation](../internal/cli/cli.go) |
7575
| Independent trust assessment | Typed authenticity/integrity/completeness results, full evidence recomputation, and outside review of trust/digest changes | [SDD W4](software-design-document.md#12-sequential-work-packages), [bind resolution](../internal/attest/bind.go) |
@@ -91,8 +91,8 @@ needs fresh verification and human review; invitations still require A2 and A3.
9191
|---|---|---|
9292
| 1 | CUR-01: explicit release/advertisement evidence; declared resource checks including samples; complete static homepage CSS | Corrections on this branch; run `python3 scripts/test_public_assets.py` and `python3 scripts/check-public-assets.py --verify-release` |
9393
| 2 | Action execution: reject consumer-controlled source builds; treat inputs as shell/JavaScript data | In progress on Action P1 — [resolver](../scripts/action-resolve-bin.sh); consumer path = checksum-pinned download only; dogfood source only via explicit RI-SE env |
94-
| 3 | Contained, staged output writes; exclusive writers and interruption recovery; Windows path cases | Open — default `review-pack` symlink escape reproduced in [release writer](../internal/release/release.go); pull concurrency/path work forward |
95-
| 4 | W2 canonical evaluation/receipt split, explicit `as_of`, complete identity, versioned cache; producer and reader determinism | Openthe three-site CUR-CLOCK substitution alone is insufficient; [SDD](software-design-document.md#12-sequential-work-packages) |
94+
| 3 | Contained, staged output writes; exclusive writers and interruption recovery; Windows path cases | Closed structurally on tip after [#55](https://github.com/RI-SE/curbpack/pull/55) (`de116cd`); three-alias cache still not one transaction ([outwrite](../internal/outwrite/outwrite.go)) |
95+
| 4 | W2 canonical evaluation/receipt split, explicit `as_of`, complete identity, versioned cache; producer and reader determinism | **Partial**epoch reject + eval/receipt skeleton + legacy adapter + unset-epoch HOME-stable canonical bytes; `as_of` binding and CA-by-digest cache remain open ([SDD](software-design-document.md#12-sequential-work-packages)) |
9696
| 5 | Explicit redaction context; failed/evaluated/skipped counts; comparable trends; `conformity_claim: none`; schemas and compatibility | Open — preserve custom-home leak detection and historical machine contracts |
9797
| 6 | Extend existing offline bundle review with schema/integrity validation and separate trust results | Open — share the existing review engine rather than introducing an independent verifier |
9898

internal/attest/attest.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,9 +146,13 @@ func Run(opts Options) (Capsule, error) {
146146
evidence["pack_ids"] = packIDs
147147
}
148148

149+
ts, err := clock.RFC3339()
150+
if err != nil {
151+
return Capsule{}, err
152+
}
149153
cap := Capsule{
150154
SchemaVersion: SchemaVersion,
151-
Timestamp: clock.RFC3339(), // display-only; not in state_hash
155+
Timestamp: ts, // display-only; not in state_hash
152156
CommitSHA: commit,
153157
StateHash: stateHash,
154158
ParentStateHash: parentHash,

internal/cli/cli.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -786,7 +786,10 @@ func cmdAttest(args []string) error {
786786
if verr != nil {
787787
return fmt.Errorf("VEX evidence: validate failed while binding digests: %w", verr)
788788
}
789-
doc := vex.FromGateFailures(filepath.Base(root), res.Payload)
789+
doc, werr := vex.FromGateFailures(filepath.Base(root), res.Payload)
790+
if werr != nil {
791+
return fmt.Errorf("VEX evidence: %w", werr)
792+
}
790793
if _, werr := vex.Write(root, doc, ""); werr != nil {
791794
return fmt.Errorf("VEX write failed while binding digests: %w", werr)
792795
}

internal/cli/scan.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,18 @@ func cmdScan(args []string) error {
5757
}
5858

5959
notStarted, failing := classifyFindings(res.Payload.Failures)
60-
days := clock.DaysUntilUTC(clock.Art14ReportingStart)
60+
days, err := clock.DaysUntilUTC(clock.Art14ReportingStart)
61+
if err != nil {
62+
return err
63+
}
64+
now, err := clock.NowUTC()
65+
if err != nil {
66+
return err
67+
}
6168

6269
claimLine := "Prepares evidence for human review — not a conformity assessment."
6370
if flags.badge || flags.formatMarkdown {
64-
fmt.Println(art14BadgeLine(root, clock.NowUTC()))
71+
fmt.Println(art14BadgeLine(root, now))
6572
fmt.Println(claimLine)
6673
return nil
6774
}

internal/clock/clock.go

Lines changed: 59 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,61 @@
11
package clock
22

33
import (
4+
"errors"
5+
"fmt"
46
"os"
57
"strconv"
68
"time"
79
)
810

9-
// NowUTC returns UTC time for artifact timestamps.
11+
// ErrInvalidSourceDateEpoch is returned when SOURCE_DATE_EPOCH is set but is
12+
// not a non-negative Unix second. Callers must not fall back to wall clock.
13+
var ErrInvalidSourceDateEpoch = errors.New("invalid SOURCE_DATE_EPOCH")
14+
15+
// ParseSourceDateEpoch reports the pinned epoch when SOURCE_DATE_EPOCH is set.
16+
// Unset/empty → (zero, false, nil). Invalid → (_, false, ErrInvalidSourceDateEpoch).
17+
// A value that is only whitespace is invalid (the variable is set but unusable).
18+
func ParseSourceDateEpoch() (time.Time, bool, error) {
19+
raw, set := os.LookupEnv("SOURCE_DATE_EPOCH")
20+
if !set {
21+
return time.Time{}, false, nil
22+
}
23+
v := stringsTrim(raw)
24+
if v == "" {
25+
return time.Time{}, false, fmt.Errorf("%w: %q (want non-negative Unix seconds)", ErrInvalidSourceDateEpoch, raw)
26+
}
27+
sec, err := strconv.ParseInt(v, 10, 64)
28+
if err != nil || sec < 0 {
29+
return time.Time{}, false, fmt.Errorf("%w: %q (want non-negative Unix seconds)", ErrInvalidSourceDateEpoch, v)
30+
}
31+
return time.Unix(sec, 0).UTC(), true, nil
32+
}
33+
34+
// CheckSourceDateEpoch returns nil when unset or valid; otherwise wraps ErrInvalidSourceDateEpoch.
35+
func CheckSourceDateEpoch() error {
36+
_, _, err := ParseSourceDateEpoch()
37+
return err
38+
}
39+
40+
// NowUTC returns UTC time for operational timestamps (receipts, display).
1041
// Tests and reproducible builds may pin via SOURCE_DATE_EPOCH (Unix seconds).
11-
func NowUTC() time.Time {
12-
if v := stringsTrim(os.Getenv("SOURCE_DATE_EPOCH")); v != "" {
13-
if sec, err := strconv.ParseInt(v, 10, 64); err == nil && sec >= 0 {
14-
return time.Unix(sec, 0).UTC()
15-
}
42+
// Invalid SOURCE_DATE_EPOCH is rejected — there is no silent wall-clock fallback.
43+
func NowUTC() (time.Time, error) {
44+
if t, set, err := ParseSourceDateEpoch(); err != nil {
45+
return time.Time{}, err
46+
} else if set {
47+
return t, nil
1648
}
17-
return time.Now().UTC()
49+
return time.Now().UTC(), nil
1850
}
1951

2052
// RFC3339 returns a UTC RFC3339 timestamp using NowUTC.
21-
func RFC3339() string {
22-
return NowUTC().Format(time.RFC3339)
53+
func RFC3339() (string, error) {
54+
t, err := NowUTC()
55+
if err != nil {
56+
return "", err
57+
}
58+
return t.Format(time.RFC3339), nil
2359
}
2460

2561
// EvidenceEpoch is the fixed synthetic UTC timestamp used for digest-bound
@@ -30,11 +66,14 @@ const EvidenceEpoch = "1970-01-01T00:00:00Z"
3066
// evidence (SBOM metadata.timestamp / VEX timestamp). Honors SOURCE_DATE_EPOCH
3167
// when set; otherwise uses EvidenceEpoch so re-attest on the same inputs is
3268
// idempotent without encoding hash entropy into the clock field.
33-
func RFC3339ForEvidence() string {
34-
if v := stringsTrim(os.Getenv("SOURCE_DATE_EPOCH")); v != "" {
35-
return RFC3339()
69+
// Invalid SOURCE_DATE_EPOCH is rejected (no silent wall-clock fallback).
70+
func RFC3339ForEvidence() (string, error) {
71+
if _, set, err := ParseSourceDateEpoch(); err != nil {
72+
return "", err
73+
} else if !set {
74+
return EvidenceEpoch, nil
3675
}
37-
return EvidenceEpoch
76+
return RFC3339()
3877
}
3978

4079
// Art14ReportingStart is the CRA Art 14 reporting clock start (UTC date).
@@ -43,10 +82,14 @@ var Art14ReportingStart = time.Date(2026, 9, 11, 0, 0, 0, 0, time.UTC)
4382

4483
// DaysUntilUTC returns whole calendar days from NowUTC (date-truncated) until deadline.
4584
// Negative when the deadline has passed.
46-
func DaysUntilUTC(deadline time.Time) int {
47-
now := NowUTC().Truncate(24 * time.Hour)
85+
func DaysUntilUTC(deadline time.Time) (int, error) {
86+
now, err := NowUTC()
87+
if err != nil {
88+
return 0, err
89+
}
90+
now = now.Truncate(24 * time.Hour)
4891
d := deadline.UTC().Truncate(24 * time.Hour)
49-
return int(d.Sub(now).Hours() / 24)
92+
return int(d.Sub(now).Hours() / 24), nil
5093
}
5194

5295
// FormatArt14Countdown formats days until Art14ReportingStart for site HTML.

internal/clock/clock_test.go

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package clock_test
22

33
import (
4+
"errors"
45
"os"
56
"testing"
67
"time"
@@ -28,7 +29,10 @@ func TestFormatArt14Countdown(t *testing.T) {
2829

2930
func TestFormatArt14CountdownPastDeadline(t *testing.T) {
3031
t.Setenv("SOURCE_DATE_EPOCH", "1789171200") // 2026-09-12 UTC (day after Art14ReportingStart)
31-
days := clock.DaysUntilUTC(clock.Art14ReportingStart)
32+
days, err := clock.DaysUntilUTC(clock.Art14ReportingStart)
33+
if err != nil {
34+
t.Fatal(err)
35+
}
3236
if days != -1 {
3337
t.Fatalf("want -1 day after deadline, got %d", days)
3438
}
@@ -41,7 +45,10 @@ func TestFormatArt14CountdownPastDeadline(t *testing.T) {
4145

4246
func TestDaysUntilArt14Reporting(t *testing.T) {
4347
t.Setenv("SOURCE_DATE_EPOCH", "1757548800") // 2025-09-11 UTC
44-
days := clock.DaysUntilUTC(clock.Art14ReportingStart)
48+
days, err := clock.DaysUntilUTC(clock.Art14ReportingStart)
49+
if err != nil {
50+
t.Fatal(err)
51+
}
4552
if days != 365 {
4653
t.Fatalf("want 365 days until 2026-09-11 from 2025-09-11, got %d", days)
4754
}
@@ -53,8 +60,14 @@ func TestDaysUntilArt14Reporting(t *testing.T) {
5360
func TestRFC3339ForEvidenceStableWithoutEpoch(t *testing.T) {
5461
t.Setenv("SOURCE_DATE_EPOCH", "")
5562
_ = os.Unsetenv("SOURCE_DATE_EPOCH")
56-
a := clock.RFC3339ForEvidence()
57-
b := clock.RFC3339ForEvidence()
63+
a, err := clock.RFC3339ForEvidence()
64+
if err != nil {
65+
t.Fatal(err)
66+
}
67+
b, err := clock.RFC3339ForEvidence()
68+
if err != nil {
69+
t.Fatal(err)
70+
}
5871
if a != b {
5972
t.Fatalf("evidence RFC3339 drifted: %q vs %q", a, b)
6073
}
@@ -65,9 +78,45 @@ func TestRFC3339ForEvidenceStableWithoutEpoch(t *testing.T) {
6578

6679
func TestRFC3339ForEvidenceHonorsSourceDateEpoch(t *testing.T) {
6780
t.Setenv("SOURCE_DATE_EPOCH", "1704067200") // 2024-01-01 UTC
68-
got := clock.RFC3339ForEvidence()
81+
got, err := clock.RFC3339ForEvidence()
82+
if err != nil {
83+
t.Fatal(err)
84+
}
6985
want := "2024-01-01T00:00:00Z"
7086
if got != want {
7187
t.Fatalf("RFC3339ForEvidence() = %q, want %q", got, want)
7288
}
7389
}
90+
91+
func TestRejectInvalidSourceDateEpoch(t *testing.T) {
92+
for _, bad := range []string{"not-a-number", "-1", "1.5", "0x10", " "} {
93+
t.Run(bad, func(t *testing.T) {
94+
t.Setenv("SOURCE_DATE_EPOCH", bad)
95+
if err := clock.CheckSourceDateEpoch(); err == nil {
96+
t.Fatal("CheckSourceDateEpoch: want error for invalid epoch")
97+
} else if !errors.Is(err, clock.ErrInvalidSourceDateEpoch) {
98+
t.Fatalf("CheckSourceDateEpoch: %v, want ErrInvalidSourceDateEpoch", err)
99+
}
100+
if _, err := clock.NowUTC(); err == nil || !errors.Is(err, clock.ErrInvalidSourceDateEpoch) {
101+
t.Fatalf("NowUTC: %v, want ErrInvalidSourceDateEpoch", err)
102+
}
103+
if _, err := clock.RFC3339(); err == nil || !errors.Is(err, clock.ErrInvalidSourceDateEpoch) {
104+
t.Fatalf("RFC3339: %v, want ErrInvalidSourceDateEpoch", err)
105+
}
106+
if _, err := clock.RFC3339ForEvidence(); err == nil || !errors.Is(err, clock.ErrInvalidSourceDateEpoch) {
107+
t.Fatalf("RFC3339ForEvidence: %v, want ErrInvalidSourceDateEpoch", err)
108+
}
109+
})
110+
}
111+
}
112+
113+
func TestUnsetSourceDateEpochAllowsWallClock(t *testing.T) {
114+
t.Setenv("SOURCE_DATE_EPOCH", "")
115+
_ = os.Unsetenv("SOURCE_DATE_EPOCH")
116+
if err := clock.CheckSourceDateEpoch(); err != nil {
117+
t.Fatal(err)
118+
}
119+
if _, err := clock.NowUTC(); err != nil {
120+
t.Fatal(err)
121+
}
122+
}

internal/exportx/optional.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,18 @@ func WriteSPDXOptional(root, outPath string) (string, error) {
1818
if err != nil && !sbom.IsUnavailable(err) {
1919
return "", err
2020
}
21+
created, err := clock.RFC3339()
22+
if err != nil {
23+
return "", err
24+
}
2125
docs := map[string]any{
2226
"spdxVersion": "SPDX-2.3",
2327
"dataLicense": "CC0-1.0",
2428
"SPDXID": "SPDXRef-DOCUMENT",
2529
"name": filepath.Base(root) + "-sbom",
2630
"documentNamespace": "https://curbpack.local/spdx/" + filepath.Base(root),
2731
"creationInfo": map[string]any{
28-
"created": clock.RFC3339(),
32+
"created": created,
2933
"creators": []string{"Tool: curbpack"},
3034
"comment": "Optional SPDX mirror of component list — not a certification. Source=" + source,
3135
},
@@ -80,9 +84,10 @@ func WriteSLSAOptional(root, outPath string) (string, error) {
8084
{"uri": "sbom.cdx.json", "digest": map[string]string{"sha256": sbomDig}},
8185
},
8286
"metadata": map[string]any{
83-
"buildFinishedOn": clock.RFC3339(),
84-
"completeness": map[string]any{"parameters": false, "environment": false, "materials": false},
85-
"comment": "Optional SLSA-shaped sidecar wrapping local digests — does not replace Git Notes attest honesty. Not a certification.",
87+
// Omit buildFinishedOn when no real build-event time is known (W2:
88+
// do not synthesize a completion date from wall clock).
89+
"completeness": map[string]any{"parameters": false, "environment": false, "materials": false},
90+
"comment": "Optional SLSA-shaped sidecar wrapping local digests — does not replace Git Notes attest honesty. Not a certification.",
8691
},
8792
}
8893
doc := map[string]any{

internal/invariants/capsule_vex_test.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,10 @@ func TestHealVEXStaysDraft(t *testing.T) {
3333
SanitizedDescription: "missing",
3434
}},
3535
}
36-
doc := vex.FromGateFailures("app", payload)
36+
doc, err := vex.FromGateFailures("app", payload)
37+
if err != nil {
38+
t.Fatal(err)
39+
}
3740
if doc.Status != "draft_pending_attest" {
3841
t.Fatalf("status=%q want draft_pending_attest", doc.Status)
3942
}

0 commit comments

Comments
 (0)