1
//! Contract tests for the artifact layout.
2
//!
3
//! These run on every branch and PR via `cargo test -p xtask`, which matters
4
//! because the S3 transport they guard is master-only: without them the rules
5
//! master depends on would first execute after a merge.
6

            
7
use super::*;
8

            
9
const DIGEST: &str = "@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
10

            
11
12
fn identity(run_id: &str, attempt: &str) -> Identity {
12
12
    Identity {
13
12
        commit: "abc123".into(),
14
12
        run_id: run_id.into(),
15
12
        run_attempt: attempt.into(),
16
12
        builder: format!("registry.example/nomisync-builder:1.96.1{DIGEST}"),
17
12
    }
18
12
}
19

            
20
11
fn meta(kind: Kind, sha: &str, identity: Identity) -> Meta {
21
11
    Meta {
22
11
        kind,
23
11
        sha256: sha.into(),
24
11
        bytes: 10,
25
11
        identity,
26
11
    }
27
11
}
28

            
29
#[test]
30
1
fn prefix_is_commit_then_run_id() {
31
1
    assert_eq!(
32
1
        identity("77", "1").prefix("buck"),
33
        "s3://buck/abc123/77",
34
        "the prefix is what makes cross-run and cross-commit mixing unreachable"
35
    );
36
1
}
37

            
38
#[test]
39
1
fn different_bytes_give_a_different_object_key() {
40
1
    let a = meta(Kind::Coverage, "deadbeef", identity("77", "1")).object_key();
41
1
    let b = meta(Kind::Coverage, "cafebabe", identity("77", "1")).object_key();
42
1
    assert_ne!(
43
        a, b,
44
        "content addressing is what makes an overwrite-with-different-bytes impossible"
45
    );
46
1
}
47

            
48
#[test]
49
1
fn meta_round_trips() {
50
1
    let original = meta(Kind::Release, "beef", identity("77", "2"));
51
1
    let parsed = Meta::from_text(&original.to_text()).expect("meta should parse");
52
1
    assert_eq!(parsed, original);
53
1
}
54

            
55
#[test]
56
1
fn meta_is_parsed_not_evaluated() {
57
    // A meta is fetched from object storage. The shell version had to avoid
58
    // sourcing it; here the equivalent mistake is not expressible, and this
59
    // pins that a shell-ish payload survives as inert text.
60
1
    let text = "kind=coverage\nobject=$(touch /tmp/pwned).tar\nsha256=dead\nbytes=1\n\
61
1
                commit=abc123\nrun_id=77\nrun_attempt=1\nbuilder=x@sha256:y\n";
62
1
    let parsed = Meta::from_text(text).expect("meta should parse");
63
1
    assert_eq!(parsed.sha256, "dead");
64
1
}
65

            
66
#[test]
67
1
fn meta_missing_a_field_is_an_error() {
68
1
    let text = "kind=coverage\nsha256=dead\nbytes=1\n";
69
1
    assert!(
70
1
        Meta::from_text(text).is_err(),
71
        "an incomplete meta must not parse"
72
    );
73
1
}
74

            
75
#[test]
76
1
fn failed_only_rerun_is_a_valid_pair() {
77
    // Coverage passed in attempt 1, release failed and was re-run as attempt 2.
78
    // Same commit, same run, same builder — usable. Refusing this would force a
79
    // ~35 minute coverage rebuild because an unrelated job flaked.
80
1
    let coverage = meta(Kind::Coverage, "dead", identity("77", "1"));
81
1
    let release = meta(Kind::Release, "beef", identity("77", "2"));
82
1
    assert_eq!(check_pair(&coverage, &release), Ok(()));
83
1
}
84

            
85
#[test]
86
1
fn artifacts_from_different_runs_are_refused() {
87
1
    let coverage = meta(Kind::Coverage, "dead", identity("77", "1"));
88
1
    let release = meta(Kind::Release, "beef", identity("78", "1"));
89
1
    assert!(matches!(
90
1
        check_pair(&coverage, &release),
91
        Err(PairError::Run { .. })
92
    ));
93
1
}
94

            
95
#[test]
96
1
fn artifacts_from_different_commits_are_refused() {
97
1
    let coverage = meta(Kind::Coverage, "dead", identity("77", "1"));
98
1
    let mut other = identity("77", "1");
99
1
    other.commit = "def456".into();
100
1
    let release = meta(Kind::Release, "beef", other);
101
1
    assert!(matches!(
102
1
        check_pair(&coverage, &release),
103
        Err(PairError::Commit { .. })
104
    ));
105
1
}
106

            
107
#[test]
108
1
fn artifacts_from_different_builder_images_are_refused() {
109
    // A mid-run image change must not produce a mixed-toolchain pair.
110
1
    let coverage = meta(Kind::Coverage, "dead", identity("77", "1"));
111
1
    let mut other = identity("77", "1");
112
1
    other.builder = format!("registry.example/nomisync-builder:1.96.2{DIGEST}");
113
1
    let release = meta(Kind::Release, "beef", other);
114
1
    assert!(matches!(
115
1
        check_pair(&coverage, &release),
116
        Err(PairError::Builder { .. })
117
    ));
118
1
}
119

            
120
/// Environment as a map, so these never touch the real process environment.
121
5
fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> + use<> {
122
5
    let owned: Vec<(String, String)> = pairs
123
5
        .iter()
124
15
        .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
125
5
        .collect();
126
31
    move |key| owned.iter().find(|(k, _)| k == key).map(|(_, v)| v.clone())
127
5
}
128

            
129
#[test]
130
1
fn identity_requires_a_digest_pinned_builder() {
131
    // Guards the reason the digest pin exists: a tag can be moved, so it cannot
132
    // prove two producers ran the same image.
133
1
    let err = Identity::from_source(env(&[
134
1
        ("SHA", "abc123"),
135
1
        ("GITHUB_RUN_ID", "77"),
136
1
        ("RUST_BUILDER_IMAGE_REF", "registry.example/builder:1.96.1"),
137
1
    ]))
138
1
    .expect_err("a tag-only builder ref must be refused");
139
1
    assert!(format!("{err}").contains("not digest-pinned"), "{err}");
140
1
}
141

            
142
#[test]
143
1
fn identity_refuses_a_missing_run_id() {
144
1
    let err = Identity::from_source(env(&[
145
1
        ("SHA", "abc123"),
146
1
        (
147
1
            "RUST_BUILDER_IMAGE_REF",
148
1
            "registry.example/builder:1.96.1@sha256:aa",
149
1
        ),
150
1
    ]))
151
1
    .expect_err("a missing run number must not default");
152
1
    assert!(format!("{err}").contains("GITHUB_RUN_ID"), "{err}");
153
1
}
154

            
155
#[test]
156
1
fn identity_refuses_an_empty_run_id() {
157
    // ci-build forwards this undefaulted, so empty is the shape a missing value
158
    // actually arrives in.
159
1
    let err = Identity::from_source(env(&[
160
1
        ("SHA", "abc123"),
161
1
        ("GITHUB_RUN_ID", ""),
162
1
        (
163
1
            "RUST_BUILDER_IMAGE_REF",
164
1
            "registry.example/builder:1.96.1@sha256:aa",
165
1
        ),
166
1
    ]))
167
1
    .expect_err("an empty run number must not be accepted");
168
1
    assert!(format!("{err}").contains("GITHUB_RUN_ID"), "{err}");
169
1
}
170

            
171
#[test]
172
1
fn identity_accepts_a_complete_environment() {
173
1
    let identity = Identity::from_source(env(&[
174
1
        ("SHA", "abc123"),
175
1
        ("GITHUB_RUN_ID", "77"),
176
1
        ("GITHUB_RUN_ATTEMPT", "2"),
177
1
        (
178
1
            "RUST_BUILDER_IMAGE_REF",
179
1
            "registry.example/builder:1.96.1@sha256:aa",
180
1
        ),
181
1
    ]))
182
1
    .expect("complete identity should be accepted");
183
1
    assert_eq!(identity.run_attempt, "2");
184
1
}
185

            
186
#[test]
187
1
fn a_missing_attempt_degrades_rather_than_failing() {
188
    // The attempt is provenance detail, not a path component, so a platform
189
    // that does not supply it must still be able to publish.
190
1
    let identity = Identity::from_source(env(&[
191
1
        ("SHA", "abc123"),
192
1
        ("GITHUB_RUN_ID", "77"),
193
1
        (
194
1
            "RUST_BUILDER_IMAGE_REF",
195
1
            "registry.example/builder:1.96.1@sha256:aa",
196
1
        ),
197
1
    ]))
198
1
    .expect("a missing attempt must not be fatal");
199
1
    assert_eq!(identity.run_attempt, "unknown");
200
1
}