bbcloud 0.11.0

Bitbucket Cloud CLI — open pull requests, read every comment, write replies, from the shell
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
#![allow(clippy::unwrap_used)]

use assert_cmd::Command;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};

/// The release payload shape `bb update` reads: only `tag_name` matters for
/// the comparison.
fn release_body(tag: &str) -> serde_json::Value {
    serde_json::json!({ "tag_name": tag, "assets": [] })
}

/// Every binary invocation in this file must go through here: it points
/// `HOME` and `XDG_CONFIG_HOME` at a per-test tempdir (so `refresh_tracked`'s
/// unconditional `save_state` can never touch the developer's real
/// `~/.config/bb/skills.json`) and disables the keyring, since `bb update`
/// should never reach it. Returns the tempdir too so callers that need to
/// assert on the config path can keep it alive.
fn bb(api: &str) -> (Command, tempfile::TempDir) {
    let cfg = tempfile::tempdir().unwrap();
    let mut cmd = Command::cargo_bin("bb").unwrap();
    cmd.env("HOME", cfg.path())
        .env("XDG_CONFIG_HOME", cfg.path())
        .env("BB_UPDATE_API_BASE", api)
        .env("BB_KEYRING_DISABLE", "1")
        .env("NO_COLOR", "1");
    (cmd, cfg)
}

#[tokio::test]
async fn reports_up_to_date_in_json_when_the_latest_tag_matches() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/repos/biokraft/bbcloud/releases/latest"))
        .respond_with(
            ResponseTemplate::new(200)
                .set_body_json(release_body(&format!("v{}", env!("CARGO_PKG_VERSION")))),
        )
        .mount(&server)
        .await;

    let (mut cmd, _cfg) = bb(&server.uri());
    let output = cmd.args(["update", "--json"]).output().unwrap();

    let stdout = String::from_utf8_lossy(&output.stdout);
    let parsed: serde_json::Value = serde_json::from_str(&stdout)
        .unwrap_or_else(|e| panic!("stdout is not pure json: {e}\n{stdout}"));
    assert_eq!(parsed["up_to_date"], serde_json::Value::Bool(true));
    assert_eq!(parsed["current"], env!("CARGO_PKG_VERSION"));
    assert_eq!(parsed["latest"], format!("v{}", env!("CARGO_PKG_VERSION")));
}

/// The most important test in this file. `api::Client` attaches the Basic auth
/// header unconditionally; `update` must NOT use it, because the token belongs
/// to Bitbucket and this request goes to GitHub.
#[tokio::test]
async fn the_api_token_is_never_sent_to_the_release_host() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/repos/biokraft/bbcloud/releases/latest"))
        .respond_with(ResponseTemplate::new(200).set_body_json(release_body("v0.0.1")))
        .mount(&server)
        .await;

    let (mut cmd, _cfg) = bb(&server.uri());
    cmd.args(["update", "--json"])
        .env("BB_EMAIL", "dev@example.com")
        .env("BB_TOKEN", "ATATT-super-secret-value")
        .output()
        .unwrap();

    for request in server.received_requests().await.unwrap() {
        assert!(
            request.headers.get("authorization").is_none(),
            "update sent an Authorization header to the release host"
        );
        let serialized = format!("{:?}", request.headers);
        assert!(
            !serialized.contains("ATATT-super-secret-value"),
            "the api token leaked into a request header: {serialized}"
        );
    }
}

/// A newer release whose assets are missing must fail loudly and leave the
/// running binary byte-for-byte unchanged. This is the verify-before-write
/// guarantee: nothing is written next to the executable until a download has
/// been fetched AND its digest matched.
#[tokio::test]
async fn a_newer_release_with_missing_assets_fails_without_touching_the_binary() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/repos/biokraft/bbcloud/releases/latest"))
        .respond_with(ResponseTemplate::new(200).set_body_json(release_body("v99.0.0")))
        .mount(&server)
        .await;

    let exe = assert_cmd::cargo::cargo_bin("bb");
    let before = std::fs::read(&exe).unwrap();

    let (mut cmd, _cfg) = bb(&server.uri());
    let output = cmd.args(["update", "--json"]).output().unwrap();

    assert!(!output.status.success(), "a failed update must not exit 0");
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.contains("missing") || stderr.contains("asset"),
        "the error should name the missing asset, got: {stderr}"
    );
    assert_eq!(
        std::fs::read(&exe).unwrap(),
        before,
        "the running binary was modified despite the update failing"
    );
}

/// Builds a valid tar.gz whose only entry named `bb` is a link of the given
/// type pointing at `target`. The header's size must be set explicitly to 0
/// and the entry type set explicitly — `tar::Header::new_gnu()` otherwise
/// leaves the size field blank, which makes the *reader* fail during tar
/// parsing (`numeric field was not a number: ... for bb`) before
/// `entry_type()` is ever consulted. An archive that fails to parse would
/// make this test pass for the wrong reason: it must be well-formed so the
/// rejection comes from the `is_file()` check under test, not from a parse
/// error that the pre-fix code would have hit identically.
fn build_link_archive(entry_type: tar::EntryType, target: &str) -> Vec<u8> {
    use std::io::Write;

    let mut tar_bytes = Vec::new();
    {
        let mut builder = tar::Builder::new(&mut tar_bytes);
        let mut header = tar::Header::new_gnu();
        header.set_size(0);
        header.set_entry_type(entry_type);
        header.set_mode(0o644);
        builder.append_link(&mut header, "bb", target).unwrap();
        builder.finish().unwrap();
    }
    let mut archive_bytes = Vec::new();
    {
        let mut encoder =
            flate2::write::GzEncoder::new(&mut archive_bytes, flate2::Compression::default());
        encoder.write_all(&tar_bytes).unwrap();
        encoder.finish().unwrap();
    }
    archive_bytes
}

/// Runs `bb update` against a `bb`-named archive entry of the given link
/// type pointing at a freshly created victim file, and asserts the whole
/// verify-before-write / reject-non-file contract holds: non-zero exit, no
/// staged file left behind, the victim untouched, and the running binary
/// byte-for-byte unchanged.
async fn assert_link_entry_is_rejected(entry_type: tar::EntryType) {
    use sha2::{Digest, Sha256};
    #[cfg(unix)]
    use std::os::unix::fs::PermissionsExt;

    let tmp = tempfile::tempdir().unwrap();
    let victim = tmp.path().join("victim");
    std::fs::write(&victim, b"do not touch me").unwrap();
    #[cfg(unix)]
    std::fs::set_permissions(&victim, std::fs::Permissions::from_mode(0o644)).unwrap();

    let archive_bytes = build_link_archive(entry_type, victim.to_str().unwrap());
    let digest = format!("{:x}", Sha256::digest(&archive_bytes));

    let triple = bb_cli::commands::update::current_triple().unwrap();
    let tag = "v99.0.0";
    let (archive_name, checksum_name) = bb_cli::commands::update::asset_names(tag, triple);

    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/repos/biokraft/bbcloud/releases/latest"))
        .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
            "tag_name": tag,
            "assets": [
                {
                    "name": archive_name,
                    "browser_download_url": format!("{}/assets/{archive_name}", server.uri()),
                },
                {
                    "name": checksum_name,
                    "browser_download_url": format!("{}/assets/{checksum_name}", server.uri()),
                },
            ],
        })))
        .mount(&server)
        .await;
    Mock::given(method("GET"))
        .and(path(format!("/assets/{archive_name}")))
        .respond_with(ResponseTemplate::new(200).set_body_bytes(archive_bytes))
        .mount(&server)
        .await;
    Mock::given(method("GET"))
        .and(path(format!("/assets/{checksum_name}")))
        .respond_with(ResponseTemplate::new(200).set_body_string(digest))
        .mount(&server)
        .await;

    let exe = assert_cmd::cargo::cargo_bin("bb");
    let before = std::fs::read(&exe).unwrap();
    let exe_dir = exe.parent().unwrap().to_path_buf();

    let (mut cmd, _cfg) = bb(&server.uri());
    let output = cmd.args(["update", "--json"]).output().unwrap();

    assert!(
        !output.status.success(),
        "a rejected {entry_type:?} entry must not exit 0"
    );

    for entry in std::fs::read_dir(&exe_dir).unwrap() {
        let name = entry.unwrap().file_name();
        assert!(
            !name.to_string_lossy().starts_with(".bb-update-staged"),
            "a staged file was left behind: {name:?}"
        );
    }

    let victim_meta = std::fs::symlink_metadata(&victim).unwrap();
    assert!(
        !victim_meta.file_type().is_symlink(),
        "victim should still be a regular file"
    );
    #[cfg(unix)]
    assert_eq!(
        victim_meta.permissions().mode() & 0o777,
        0o644,
        "victim's permissions must be untouched"
    );
    assert_eq!(
        std::fs::read(&victim).unwrap(),
        b"do not touch me",
        "victim's contents must be untouched"
    );

    assert_eq!(
        std::fs::read(&exe).unwrap(),
        before,
        "the running binary was modified despite the {entry_type:?} rejection"
    );
}

/// Pins the Critical fix: a `bb` entry that is a symlink rather than a
/// regular file must be rejected, not unpacked. `tar::Entry::unpack` skips
/// link validation when given an explicit destination with no `target_base`,
/// so unpacking a symlink entry directly would chmod/replace whatever it
/// points at, entirely outside the install directory.
#[tokio::test]
async fn a_symlink_bb_entry_is_rejected_and_leaves_everything_untouched() {
    assert_link_entry_is_rejected(tar::EntryType::Symlink).await;
}

/// Same contract, for a hard-link entry. `Entry::unpack`'s link-handling
/// branch covers both link types, and the original finding named both.
#[tokio::test]
async fn a_hard_link_bb_entry_is_rejected_and_leaves_everything_untouched() {
    assert_link_entry_is_rejected(tar::EntryType::Link).await;
}

/// 403 with `x-ratelimit-remaining: 0` and a valid reset header must be
/// reported as a rate limit, with the retry time derived from the header
/// (not hardcoded, so the assertion holds in any timezone).
#[tokio::test]
async fn rate_limited_403_with_reset_header_reports_retry_time() {
    let server = MockServer::start().await;
    let reset_epoch: i64 = 1_786_452_151;
    Mock::given(method("GET"))
        .and(path("/repos/biokraft/bbcloud/releases/latest"))
        .respond_with(
            ResponseTemplate::new(403)
                .insert_header("x-ratelimit-remaining", "0")
                .insert_header("x-ratelimit-reset", reset_epoch.to_string().as_str()),
        )
        .mount(&server)
        .await;

    let (mut cmd, _cfg) = bb(&server.uri());
    let output = cmd.args(["update", "--json"]).output().unwrap();

    assert!(
        !output.status.success(),
        "a rate-limited update must not exit 0"
    );
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.contains("rate limit"),
        "stderr should name the rate limit, got: {stderr}"
    );

    let expected_time = chrono::DateTime::from_timestamp(reset_epoch, 0)
        .unwrap()
        .with_timezone(&chrono::Local)
        .format("%H:%M")
        .to_string();
    assert!(
        stderr.contains(&expected_time),
        "stderr should contain the retry time {expected_time}, got: {stderr}"
    );
}

/// A missing or unparseable reset header must never yield a bogus 1970
/// timestamp or a panic — the retry time is simply omitted.
#[tokio::test]
async fn rate_limited_403_without_reset_header_omits_the_time_without_panicking() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/repos/biokraft/bbcloud/releases/latest"))
        .respond_with(ResponseTemplate::new(403).insert_header("x-ratelimit-remaining", "0"))
        .mount(&server)
        .await;

    let (mut cmd, _cfg) = bb(&server.uri());
    let output = cmd.args(["update", "--json"]).output().unwrap();

    assert!(!output.status.success());
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.contains("rate limit"),
        "stderr should still name the rate limit, got: {stderr}"
    );
    assert!(
        !stderr.contains("1970"),
        "stderr must never show a 1970 fallback timestamp, got: {stderr}"
    );
}

/// A 403 that carries no rate-limit signal (or a non-zero remaining count)
/// must be reported as a plain release-api error, and must not falsely
/// claim a rate limit.
#[tokio::test]
async fn non_rate_limit_403_reports_a_plain_release_api_error() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/repos/biokraft/bbcloud/releases/latest"))
        .respond_with(ResponseTemplate::new(403))
        .mount(&server)
        .await;

    let (mut cmd, _cfg) = bb(&server.uri());
    let output = cmd.args(["update", "--json"]).output().unwrap();

    assert!(!output.status.success());
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.contains("release api error 403"),
        "stderr should name the release api error, got: {stderr}"
    );
    assert!(
        !stderr.contains("rate limit"),
        "a plain 403 must not falsely claim a rate limit, got: {stderr}"
    );
}

/// A 500 must be reported honestly as a release-api error, and must never
/// blame Bitbucket — the request went to GitHub.
#[tokio::test]
async fn server_error_500_reports_release_api_error_without_blaming_bitbucket() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/repos/biokraft/bbcloud/releases/latest"))
        .respond_with(ResponseTemplate::new(500))
        .mount(&server)
        .await;

    let (mut cmd, _cfg) = bb(&server.uri());
    let output = cmd.args(["update", "--json"]).output().unwrap();

    assert!(!output.status.success());
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        stderr.contains("release api error 500"),
        "stderr should say release api error 500, got: {stderr}"
    );
    assert!(
        !stderr.to_lowercase().contains("bitbucket"),
        "stderr must never blame bitbucket for a release-api failure, got: {stderr}"
    );
}

/// A malformed tag must not be treated as an upgrade, and must not panic.
#[tokio::test]
async fn a_malformed_remote_tag_is_not_an_upgrade() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/repos/biokraft/bbcloud/releases/latest"))
        .respond_with(ResponseTemplate::new(200).set_body_json(release_body("nightly")))
        .mount(&server)
        .await;

    let (mut cmd, _cfg) = bb(&server.uri());
    let output = cmd.args(["update", "--json"]).output().unwrap();

    assert!(output.status.success(), "should exit 0, not panic");
    let stdout = String::from_utf8_lossy(&output.stdout);
    let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
    assert_eq!(parsed["up_to_date"], serde_json::Value::Bool(true));
}

/// Regression for the bug this file used to have: every spawned `bb`
/// invocation resolved the developer's real `~/.config/bb/skills.json`
/// because none of them overrode `HOME`/`XDG_CONFIG_HOME`, and
/// `refresh_tracked` (which `update` calls on every path, including
/// up-to-date) ends in an unconditional `save_state`. Simulates "the
/// developer's real config" as a second tempdir that is never passed to the
/// child process at all — only `bb()`'s overridden `cfg` is — and proves the
/// write landed only inside the override, never inside the stand-in for the
/// real one.
#[tokio::test]
async fn update_never_touches_the_real_config_path() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/repos/biokraft/bbcloud/releases/latest"))
        .respond_with(
            ResponseTemplate::new(200)
                .set_body_json(release_body(&format!("v{}", env!("CARGO_PKG_VERSION")))),
        )
        .mount(&server)
        .await;
    let stand_in_for_real_home = tempfile::tempdir().unwrap();
    let stand_in_real_state = stand_in_for_real_home
        .path()
        .join(".config")
        .join("bb")
        .join("skills.json");

    let (mut cmd, cfg) = bb(&server.uri());
    cmd.arg("update").assert().success();

    assert!(
        !stand_in_real_state.exists(),
        "bb update must never write outside the HOME/XDG_CONFIG_HOME override: {} was created",
        stand_in_real_state.display()
    );
    // Sanity: the override itself was actually exercised (refresh_tracked's
    // unconditional save_state writes here even with nothing tracked), so
    // the assertion above isn't just "nothing ran at all".
    assert!(
        cfg.path().join("bb").join("skills.json").exists(),
        "the overridden config dir should be the one bb actually used"
    );
}