wsb-daemon 0.258.6441

wsb's daemon: the workbench, the GitHub mirror's schedule and peer continuity, for every workspace on the machine
//! The path guard, which is the only part of a tail that can be dangerous.

use super::*;
use std::fs;

/// A workspace with a logs root and one log inside it.
fn workspace() -> tempfile::TempDir {
    let dir = tempfile::TempDir::new().expect("temp dir");
    let logs = wsb::logs::root(dir.path());
    fs::create_dir_all(logs.join("test/2026-01-01")).expect("logs dir");
    fs::write(logs.join("test/2026-01-01/wsb.build.log"), "compiling\n").expect("log");
    dir
}

#[test]
fn test10237_a_log_inside_the_root_resolves() {
    let dir = workspace();
    let resolved = resolve_in_logs(
        dir.path(),
        ".wsb/disposable/logs/test/2026-01-01/wsb.build.log",
    )
    .expect("a log inside the root resolves");
    assert!(resolved.ends_with("wsb.build.log"));
}

#[test]
fn test10238_a_log_that_does_not_exist_yet_resolves() {
    let dir = workspace();
    // A step names its log when it STARTS, before writing to it. Refusing the
    // path until the first byte exists would make the record's own `log` field
    // unusable for exactly the window it was added to serve.
    resolve_in_logs(
        dir.path(),
        ".wsb/disposable/logs/test/2026-01-01/not-yet.log",
    )
    .expect("a named but unwritten log resolves");
}

#[test]
fn test10239_traversal_out_of_the_logs_root_is_refused() {
    let dir = workspace();
    fs::write(dir.path().join("secrets.wsbs"), "ciphertext").expect("secret");
    let error = resolve_in_logs(
        dir.path(),
        ".wsb/disposable/logs/test/../../../secrets.wsbs",
    )
    .expect_err("a path climbing out of the logs root is refused");
    assert!(
        error.contains("not inside the logs root"),
        "the refusal says why: {error}"
    );
}

#[test]
fn test10240_an_absolute_path_outside_the_root_is_refused() {
    let dir = workspace();
    // Absolute paths are accepted as a form — the record carries workspace
    // paths, but a person pasting one is not an attack — and then checked the
    // same way. The check is containment, not spelling.
    resolve_in_logs(dir.path(), "/etc/passwd")
        .expect_err("an absolute path outside the logs root is refused");
}

#[test]
fn test10241_a_symlink_pointing_out_of_the_root_is_refused() {
    let dir = workspace();
    fs::write(dir.path().join("outside.txt"), "not a log").expect("target");
    let link = wsb::logs::root(dir.path()).join("test/escape.log");
    #[cfg(unix)]
    std::os::unix::fs::symlink(dir.path().join("outside.txt"), &link).expect("symlink");
    #[cfg(unix)]
    {
        // Canonicalising both sides is what catches this: the path is spelled
        // entirely inside the logs root and still names a file outside it.
        resolve_in_logs(dir.path(), ".wsb/disposable/logs/test/escape.log")
            .expect_err("a symlink out of the logs root is refused");
    }
}

#[tokio::test]
async fn test10242_reading_resumes_from_the_offset() {
    let dir = workspace();
    let path = wsb::logs::root(dir.path()).join("test/2026-01-01/wsb.build.log");

    let (text, offset) = read_from(&path, 0).await.expect("first read");
    assert_eq!(text, "compiling\n");

    // Nothing new: the same offset comes back and no bytes are re-sent, which
    // is what stops a poll loop from repeating the file every half second.
    let (again, same) = read_from(&path, offset).await.expect("second read");
    assert!(again.is_empty(), "nothing was appended, so nothing is read");
    assert_eq!(same, offset);

    fs::write(&path, "compiling\nlinking\n").expect("append");
    let (more, _) = read_from(&path, offset).await.expect("third read");
    assert_eq!(more, "linking\n", "only what arrived after the offset");
}

#[tokio::test]
async fn test10243_a_truncated_log_restarts_from_its_beginning() {
    let dir = workspace();
    let path = wsb::logs::root(dir.path()).join("test/2026-01-01/wsb.build.log");
    let (_, offset) = read_from(&path, 0).await.expect("first read");

    // A rerun writes the same name. Seeking to the old offset would sit past
    // the end of the new file and report silence for the whole of a run.
    fs::write(&path, "fresh\n").expect("rewrite shorter");
    let (text, _) = read_from(&path, offset).await.expect("after truncation");
    assert_eq!(text, "fresh\n");
}