zhao-cli 0.5.3

Deterministic, offline change-review and CI gate for data transformation projects.
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
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
//! `zhao update`: replaces the current binary with a release archive
//! fetched from GitHub Releases (see issue #28) -- the only command that
//! reaches the network at all, and only to download the release binary
//! itself; it never sends anything from a user's project. Every other
//! zhao command stays fully offline, per the README's "What it doesn't
//! do" section, which this command is the deliberate, explicit exception
//! to: it only runs when a user or the Cloud Agent (per ADR 0009)
//! explicitly invokes it, never as a side effect of
//! `check`/`diff`/`lineage`.
//!
//! Kept deliberately simple: updates by release tag, not a semver-range
//! resolver. No arguments installs the latest stable release; `--nightly`
//! installs the current moving `nightly` tag; a version/tag argument pins
//! to that exact release.

use std::io::{Read, Write};
use std::path::Path;
use std::process::ExitCode;

use crate::cli::UpdateArgs;

/// `owner/repo`, for both the GitHub Releases URLs this command
/// downloads from.
const REPO: &str = "allenhori/zhao-cli";

/// Exit code for "the binary was actually replaced."
const EXIT_OK: u8 = 0;

/// Runs `zhao update` and returns the process exit code.
pub fn run(args: &UpdateArgs) -> ExitCode {
    let tag = if args.nightly {
        "nightly".to_string()
    } else if let Some(version) = &args.version {
        version.clone()
    } else {
        "latest".to_string()
    };

    match update(&tag) {
        Ok(()) => ExitCode::from(EXIT_OK),
        Err(message) => crate::engine::fail(&message),
    }
}

/// Downloads `tag`'s release archive for the current platform, extracts
/// the `zhao` binary from it, and atomically replaces the currently
/// running executable with it. Never leaves a broken/partial binary in
/// place: every failure before the final rename leaves the existing
/// binary completely untouched, and the rename itself is the one
/// operation that actually swaps it in.
fn update(tag: &str) -> Result<(), String> {
    let current_exe = std::env::current_exe()
        .map_err(|err| format!("could not determine the current executable's path: {err}"))?;
    let installed_version = env!("CARGO_PKG_VERSION");

    log(&format!("Current exe at {}", current_exe.display()));
    let target = platform_target()?;
    log(&format!("Target: {target}"));

    log("Checking for the requested version");
    let resolved = resolve_version(tag);
    match &resolved {
        Some(version) if tag == "latest" => log(&format!(
            "Latest available version: {}",
            version.trim_start_matches('v')
        )),
        Some(version) => log(&format!("Requested version: {version}")),
        None => log(&format!("Requested version: {tag}")),
    }
    log(&format!("Current installed version: {installed_version}"));

    let archive_name = archive_name(&target);
    let url = download_url(tag, &archive_name);
    log(&format!("Downloading: {url}"));

    let archive_bytes = download(&url).map_err(|err| {
        format!(
            "could not download {url}: {err} -- check that {tag:?} is a real release tag at \
             https://github.com/{REPO}/releases"
        )
    })?;

    let binary_bytes = extract_binary(&archive_bytes, &target)?;
    log(&format!("Installing zhao to {}", current_exe.display()));
    replace_binary(&current_exe, &binary_bytes)?;

    let new_version = resolved.as_deref().unwrap_or(tag);
    log(&format!(
        "Successfully updated zhao from {installed_version} to {}",
        new_version.trim_start_matches('v')
    ));
    Ok(())
}

/// One progress line, prefixed the way `dbt system update` prefixes its
/// own installer's output so it's obvious which tool is talking.
fn log(message: &str) {
    println!("zhao update: {message}");
}

/// Best-effort lookup of the concrete version `tag` points at, purely
/// for display. `latest` follows GitHub's `/releases/latest` redirect to
/// its `/releases/tag/<tag>` URL; any other tag is already concrete. A
/// failed lookup returns `None` rather than an error -- the download
/// itself is what decides success, this only makes the output nicer.
fn resolve_version(tag: &str) -> Option<String> {
    if tag != "latest" {
        return Some(tag.to_string());
    }
    let response = ureq::head(&format!("https://github.com/{REPO}/releases/latest"))
        .call()
        .ok()?;
    tag_from_release_url(response.get_url())
}

/// Extracts `<tag>` from a `.../releases/tag/<tag>` URL.
fn tag_from_release_url(url: &str) -> Option<String> {
    url.rsplit_once("/releases/tag/")
        .map(|(_, tag)| tag.trim_end_matches('/').to_string())
        .filter(|tag| !tag.is_empty())
}

/// The four target triples zhao actually publishes release binaries
/// for -- matches `scripts/install.sh`'s own platform detection and
/// `.github/workflows/release.yml`'s build matrix exactly, so this
/// can't silently drift out of sync with what a release actually
/// contains.
fn platform_target() -> Result<String, String> {
    let os = std::env::consts::OS;
    let arch = std::env::consts::ARCH;
    match (os, arch) {
        ("macos", "aarch64") => Ok("aarch64-apple-darwin".to_string()),
        ("macos", "x86_64") => Ok("x86_64-apple-darwin".to_string()),
        ("linux", "x86_64") => Ok("x86_64-unknown-linux-gnu".to_string()),
        ("windows", "x86_64") => Ok("x86_64-pc-windows-msvc".to_string()),
        _ => Err(format!(
            "no released binary for {os}/{arch} -- build from source instead: \
             cargo install --git https://github.com/{REPO}"
        )),
    }
}

/// The release archive's filename for `target` -- `.zip` on Windows
/// (matching `Compress-Archive`'s output in `release.yml`), `.tar.gz`
/// everywhere else.
fn archive_name(target: &str) -> String {
    if cfg!(windows) {
        format!("zhao-{target}.zip")
    } else {
        format!("zhao-{target}.tar.gz")
    }
}

/// `tag == "latest"` uses GitHub's `/releases/latest/download/` alias
/// (always the newest non-prerelease release, same as
/// `scripts/install.sh`'s own default); any other tag (including
/// `"nightly"`, a real tag like every other release) is a direct
/// `/releases/download/<tag>/` URL.
fn download_url(tag: &str, archive_name: &str) -> String {
    if tag == "latest" {
        format!("https://github.com/{REPO}/releases/latest/download/{archive_name}")
    } else {
        format!("https://github.com/{REPO}/releases/download/{tag}/{archive_name}")
    }
}

/// Downloads `url`'s full response body. A non-2xx status (e.g. a
/// nonexistent tag/asset, a 404) is already a clear `Err` from `ureq`
/// itself -- no extra status-code handling needed here.
fn download(url: &str) -> Result<Vec<u8>, String> {
    let response = ureq::get(url).call().map_err(|err| err.to_string())?;
    let mut bytes = Vec::new();
    response
        .into_reader()
        .read_to_end(&mut bytes)
        .map_err(|err| err.to_string())?;
    Ok(bytes)
}

/// Extracts the `zhao`/`zhao.exe` binary's raw bytes out of a
/// downloaded release archive. `target` only matters for the entry
/// name's error message -- it's not re-validated against the archive's
/// own contents. Cfg-gated directly (rather than a runtime
/// `cfg!(windows)` branch over two always-compiled implementations),
/// matching `Cargo.toml`'s own per-platform `[target.'cfg(...)']`
/// dependency split: each build only ever needs its own platform's
/// extractor, so there's no reason to compile the other one in at all,
/// dead-code stub or otherwise.
#[cfg(not(windows))]
fn extract_binary(archive_bytes: &[u8], target: &str) -> Result<Vec<u8>, String> {
    let decoder = flate2::read::GzDecoder::new(archive_bytes);
    let mut archive = tar::Archive::new(decoder);
    let entries = archive
        .entries()
        .map_err(|err| format!("could not read {target}'s release archive: {err}"))?;
    for entry in entries {
        let mut entry =
            entry.map_err(|err| format!("could not read a release archive entry: {err}"))?;
        let path = entry
            .path()
            .map_err(|err| format!("could not read a release archive entry's path: {err}"))?;
        if path.file_name().and_then(|name| name.to_str()) == Some("zhao") {
            let mut bytes = Vec::new();
            entry
                .read_to_end(&mut bytes)
                .map_err(|err| format!("could not read the zhao binary from the archive: {err}"))?;
            return Ok(bytes);
        }
    }
    Err(format!(
        "the {target} release archive doesn't contain a `zhao` binary"
    ))
}

/// The Windows counterpart of the `.tar.gz` extractor above -- same
/// contract (`target` only for its error message's context), same
/// cfg-gating rationale.
#[cfg(windows)]
fn extract_binary(archive_bytes: &[u8], target: &str) -> Result<Vec<u8>, String> {
    let reader = std::io::Cursor::new(archive_bytes);
    let mut archive = zip::ZipArchive::new(reader)
        .map_err(|err| format!("could not read {target}'s release archive: {err}"))?;
    for i in 0..archive.len() {
        let mut file = archive
            .by_index(i)
            .map_err(|err| format!("could not read a release archive entry: {err}"))?;
        if file.name() == "zhao.exe" {
            let mut bytes = Vec::new();
            file.read_to_end(&mut bytes)
                .map_err(|err| format!("could not read the zhao binary from the archive: {err}"))?;
            return Ok(bytes);
        }
    }
    Err(format!(
        "the {target} release archive doesn't contain a zhao.exe binary"
    ))
}

/// Writes `new_binary_bytes` to a temp file in `current_exe`'s own
/// directory (guaranteeing the final swap is on the same filesystem, so
/// it's atomic), makes it executable on Unix, then swaps it into place
/// over `current_exe` -- the file backing the currently *running*
/// process. Every step before the swap can fail without touching
/// `current_exe` at all.
///
/// Unix: a single rename directly over `current_exe`. Safe unconditionally
/// -- the OS keeps the old inode alive for the still-running process; the
/// new file only takes effect on the next launch.
///
/// Windows: **not** a single rename-over, unlike Unix -- see the
/// platform-specific doc comment on the Windows `replace_binary` below
/// for why a direct replace can fail even though renaming a running
/// executable is normally allowed.
#[cfg(unix)]
fn replace_binary(current_exe: &Path, new_binary_bytes: &[u8]) -> Result<(), String> {
    let temp = write_new_binary_to_temp_file(current_exe, new_binary_bytes)?;
    temp.persist(current_exe).map_err(|err| {
        format!(
            "could not replace {}: {err} -- the previous binary is still in place, untouched",
            current_exe.display()
        )
    })?;
    Ok(())
}

/// Windows counterpart of the Unix `replace_binary` above.
///
/// A direct rename-over of `current_exe` (what `NamedTempFile::persist`
/// does, and what this crate used to do unconditionally on every
/// platform) is, in principle, allowed by Windows even against a running
/// executable's own file -- but reported in practice (issue: `zhao
/// update` failing with "Access is denied (os error 5)" on a real,
/// locked-down corporate Windows machine) to still fail on some real
/// setups, most plausibly endpoint security software specifically
/// flagging/blocking "one .exe overwriting another" as dropper-shaped
/// behavior, separately from whatever the OS itself would allow.
///
/// The fix: rename the currently-running exe *out of the way* to a
/// `.old` sibling first (a plain rename to a new, non-conflicting name,
/// not a same-name replace), then move the new binary into the now-
/// vacant original path -- the same two-step pattern `rustup` and the
/// `self_replace` crate both use for exactly this reason. The `.old`
/// file is then best-effort deleted; if that fails too (e.g. still
/// technically mapped by the about-to-exit `zhao update` process
/// itself), it's simply left behind rather than treated as an error --
/// a harmless leftover next to the binary, not a broken install. On any
/// failure *after* the exe has already been renamed out of the way,
/// the original is restored to its own path before returning, so a
/// failed update never leaves the user without a working `zhao.exe` at
/// all.
#[cfg(windows)]
fn replace_binary(current_exe: &Path, new_binary_bytes: &[u8]) -> Result<(), String> {
    let temp = write_new_binary_to_temp_file(current_exe, new_binary_bytes)?;

    let old_path = current_exe.with_extension("exe.old");
    let _ = std::fs::remove_file(&old_path); // best-effort: a leftover from a previous update

    std::fs::rename(current_exe, &old_path).map_err(|err| {
        format!(
            "could not rename {} out of the way before replacing it: {err} -- the previous \
             binary is still in place, untouched",
            current_exe.display()
        )
    })?;

    if let Err(err) = temp.persist(current_exe) {
        // The exe's own original name is now vacant -- restore it before
        // surfacing the error, so a failed update never leaves the user
        // with no working zhao.exe at all.
        let _ = std::fs::rename(&old_path, current_exe);
        return Err(format!(
            "could not move the downloaded binary into {}: {err} -- the previous binary was \
             restored, untouched",
            current_exe.display()
        ));
    }

    let _ = std::fs::remove_file(&old_path);
    Ok(())
}

/// Writes `new_binary_bytes` to a temp file in `current_exe`'s own
/// directory, and makes it executable on Unix. Shared by both
/// platforms' `replace_binary` -- everything up to (not including) the
/// actual swap into `current_exe`'s path is identical either way.
fn write_new_binary_to_temp_file(
    current_exe: &Path,
    new_binary_bytes: &[u8],
) -> Result<tempfile::NamedTempFile, String> {
    let dir = current_exe.parent().ok_or_else(|| {
        format!(
            "could not determine the directory containing {}",
            current_exe.display()
        )
    })?;

    let mut temp = tempfile::NamedTempFile::new_in(dir)
        .map_err(|err| format!("could not create a temp file in {}: {err}", dir.display()))?;
    temp.write_all(new_binary_bytes)
        .map_err(|err| format!("could not write the downloaded binary to disk: {err}"))?;
    temp.flush()
        .map_err(|err| format!("could not write the downloaded binary to disk: {err}"))?;

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let mut perms = temp
            .as_file()
            .metadata()
            .map_err(|err| format!("could not read the downloaded binary's metadata: {err}"))?
            .permissions();
        perms.set_mode(0o755);
        temp.as_file()
            .set_permissions(perms)
            .map_err(|err| format!("could not make the downloaded binary executable: {err}"))?;
    }

    Ok(temp)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn download_url_for_latest_uses_the_latest_download_alias() {
        assert_eq!(
            download_url("latest", "zhao-x86_64-apple-darwin.tar.gz"),
            "https://github.com/allenhori/zhao-cli/releases/latest/download/\
             zhao-x86_64-apple-darwin.tar.gz"
        );
    }

    #[test]
    fn download_url_for_a_specific_tag_uses_the_tagged_download_url() {
        assert_eq!(
            download_url("v0.1.1", "zhao-x86_64-apple-darwin.tar.gz"),
            "https://github.com/allenhori/zhao-cli/releases/download/v0.1.1/\
             zhao-x86_64-apple-darwin.tar.gz"
        );
    }

    #[test]
    fn download_url_for_nightly_is_just_the_nightly_tag() {
        assert_eq!(
            download_url("nightly", "zhao-x86_64-unknown-linux-gnu.tar.gz"),
            "https://github.com/allenhori/zhao-cli/releases/download/nightly/\
             zhao-x86_64-unknown-linux-gnu.tar.gz"
        );
    }

    #[test]
    fn tag_from_release_url_extracts_the_tag() {
        assert_eq!(
            tag_from_release_url("https://github.com/allenhori/zhao-cli/releases/tag/v0.5.2"),
            Some("v0.5.2".to_string())
        );
        assert_eq!(
            tag_from_release_url("https://github.com/allenhori/zhao-cli/releases"),
            None
        );
    }

    #[test]
    fn archive_name_matches_the_release_pipelines_naming() {
        // This test's own platform's extension, not a hardcoded one --
        // `archive_name` itself is `cfg!(windows)`-branching, so
        // asserting against a literal `.tar.gz` would be wrong when
        // this test happens to run on a Windows CI runner.
        let expected_ext = if cfg!(windows) { "zip" } else { "tar.gz" };
        assert_eq!(
            archive_name("x86_64-unknown-linux-gnu"),
            format!("zhao-x86_64-unknown-linux-gnu.{expected_ext}")
        );
    }

    /// `platform_target` should recognize this test's own platform --
    /// exercised against `std::env::consts::OS`/`ARCH` directly, since
    /// those are exactly what it reads.
    #[test]
    fn platform_target_recognizes_a_supported_platform() {
        if matches!(
            (std::env::consts::OS, std::env::consts::ARCH),
            ("macos", "aarch64" | "x86_64") | ("linux", "x86_64") | ("windows", "x86_64")
        ) {
            assert!(platform_target().is_ok());
        }
    }

    #[cfg(not(windows))]
    #[test]
    fn extract_tar_gz_finds_the_zhao_binary_by_name() {
        use std::io::Write;

        let mut tar_bytes = Vec::new();
        {
            let mut builder = tar::Builder::new(&mut tar_bytes);
            let contents = b"pretend binary contents";
            let mut header = tar::Header::new_gnu();
            header.set_size(contents.len() as u64);
            header.set_mode(0o755);
            header.set_cksum();
            builder
                .append_data(&mut header, "zhao", &contents[..])
                .expect("should append zhao entry");
            builder.finish().expect("should finish tar");
        }
        let mut gz_bytes = Vec::new();
        {
            let mut encoder =
                flate2::write::GzEncoder::new(&mut gz_bytes, flate2::Compression::default());
            encoder.write_all(&tar_bytes).expect("should gzip");
            encoder.finish().expect("should finish gzip");
        }

        let extracted =
            extract_binary(&gz_bytes, "x86_64-unknown-linux-gnu").expect("should extract");
        assert_eq!(extracted, b"pretend binary contents");
    }

    #[cfg(not(windows))]
    #[test]
    fn extract_tar_gz_produces_a_clear_error_when_no_zhao_entry_exists() {
        use std::io::Write;

        let mut tar_bytes = Vec::new();
        {
            let mut builder = tar::Builder::new(&mut tar_bytes);
            let contents = b"unrelated";
            let mut header = tar::Header::new_gnu();
            header.set_size(contents.len() as u64);
            header.set_cksum();
            builder
                .append_data(&mut header, "README.md", &contents[..])
                .expect("should append entry");
            builder.finish().expect("should finish tar");
        }
        let mut gz_bytes = Vec::new();
        {
            let mut encoder =
                flate2::write::GzEncoder::new(&mut gz_bytes, flate2::Compression::default());
            encoder.write_all(&tar_bytes).expect("should gzip");
            encoder.finish().expect("should finish gzip");
        }

        let result = extract_binary(&gz_bytes, "x86_64-unknown-linux-gnu");
        assert!(result.is_err(), "expected an error, got {result:?}");
    }

    /// Acceptance criterion: a failed replace never leaves a broken/
    /// partial binary in place -- here, a `current_exe` whose parent
    /// directory doesn't exist, so the temp-file step itself fails
    /// before anything ever touches the (nonexistent) target path.
    #[test]
    fn replace_binary_fails_cleanly_when_the_target_directory_does_not_exist() {
        let fake_exe = std::path::Path::new("/definitely/does/not/exist/zhao");
        let result = replace_binary(fake_exe, b"new binary");
        assert!(result.is_err());
    }

    #[test]
    fn replace_binary_actually_replaces_the_files_contents() {
        let dir = tempfile::tempdir().expect("should create temp dir");
        let exe_path = dir.path().join("zhao");
        std::fs::write(&exe_path, b"old binary").expect("should write initial binary");

        replace_binary(&exe_path, b"new binary").expect("should replace");

        let contents = std::fs::read(&exe_path).expect("should read replaced binary");
        assert_eq!(contents, b"new binary");
    }

    /// Windows-specific: the two-step rename-out/rename-in doesn't leave
    /// its `.exe.old` sidecar lying around after a successful replace --
    /// only a failed persist (restored from it, see `replace_binary`'s
    /// own doc comment) should ever leave one behind.
    ///
    /// Not exercised by this project's CI, which only runs on Ubuntu --
    /// `#[cfg(windows)]` code compiles on every platform's *checker*, but
    /// only actually runs on a real Windows machine, or a future
    /// Windows CI job.
    #[cfg(windows)]
    #[test]
    fn replace_binary_cleans_up_its_old_sidecar_file_after_a_successful_replace() {
        let dir = tempfile::tempdir().expect("should create temp dir");
        let exe_path = dir.path().join("zhao.exe");
        std::fs::write(&exe_path, b"old binary").expect("should write initial binary");

        replace_binary(&exe_path, b"new binary").expect("should replace");

        let contents = std::fs::read(&exe_path).expect("should read replaced binary");
        assert_eq!(contents, b"new binary");
        assert!(
            !exe_path.with_extension("exe.old").exists(),
            "the .exe.old sidecar should be cleaned up after a successful replace"
        );
    }

    #[cfg(unix)]
    #[test]
    fn replace_binary_makes_the_new_file_executable() {
        use std::os::unix::fs::PermissionsExt;

        let dir = tempfile::tempdir().expect("should create temp dir");
        let exe_path = dir.path().join("zhao");
        std::fs::write(&exe_path, b"old binary").expect("should write initial binary");

        replace_binary(&exe_path, b"new binary").expect("should replace");

        let mode = std::fs::metadata(&exe_path)
            .expect("should stat replaced binary")
            .permissions()
            .mode();
        assert_eq!(mode & 0o111, 0o111, "expected the file to be executable");
    }
}