OXYGENHUB

flow builds for Windows, offering the WASM tier 3554afefb6aa

The syscall tier, guest helpers and egress proxy are Linux's only; a
Windows build has a stand-in that says the syscall tier runs only on
Linux. Pruning by reads stays on Linux; modes, links and sizes go
through a portable module; artifacts are read with each step checked.
Free disk, load and kernel are not measured on Windows yet.

refs #83
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-26 01:58 UTC
Signature
◈ sealed by Andy McCall
Hash
3554afefb6aadff470e78828d68179b027f263578059e715a80fc65f5b20561c
Depends on
077a65242359 2594278f49b2 28cc603f737d 337afb8f6043 47c1d64dec8a 5cf1e8aefbb6 62829d15ef09 85275e1ef3b7 8f211e20bea5 a67b97e9a717 b14aeaae683a c3db750ed96e cc462b56fbbb d60aac6c3f8a e0918b887850 ea56c56eaf90 ea81796bb757 ecce3719da80 f201e1bc5bcd f98a8f7881e0 fc10d9e80383
Depended on by
788f57e4d266 f4dfd742b30d 494a1b9a8ac2 6f66e397539c e6949d87205b
In bookmarks
main
In tags
v0.4.0 v0.5.0 v0.5.1 v0.5.2 v0.5.3
Edits
86 atoms, 3 tree operations, 18 files

Browse the tree at this change

added crates/flow/src/portable.rs +106 −0
1 +
//! What differs between the systems a runner runs on (oxygenflow#83): file
2 +
//! modes, file identities and links are Unix's. On Windows, where a runner
3 +
//! offers the WASM tier only, modes are not kept (files read as 0644 and
4 +
//! directories as 0755), each file is its own, and sizes are lengths.
5 +
6 +
use std::fs::{Metadata, OpenOptions};
7 +
use std::io;
8 +
use std::path::Path;
9 +
10 +
/// Set `p`'s permission bits; nothing on Windows.
11 +
pub fn set_mode(p: &Path, mode: u32) -> io::Result<()> {
12 +
    #[cfg(unix)]
13 +
    {
14 +
        use std::os::unix::fs::PermissionsExt;
15 +
        std::fs::set_permissions(p, std::fs::Permissions::from_mode(mode))
16 +
    }
17 +
    #[cfg(not(unix))]
18 +
    {
19 +
        let _ = (p, mode);
20 +
        Ok(())
21 +
    }
22 +
}
23 +
24 +
/// A file's permission bits (`0o7777` of its mode).
25 +
pub fn mode(md: &Metadata) -> u32 {
26 +
    #[cfg(unix)]
27 +
    {
28 +
        use std::os::unix::fs::MetadataExt;
29 +
        md.mode() & 0o7777
30 +
    }
31 +
    #[cfg(not(unix))]
32 +
    {
33 +
        let base = if md.is_dir() { 0o755 } else { 0o644 };
34 +
        if md.permissions().readonly() {
35 +
            base & !0o222
36 +
        } else {
37 +
            base
38 +
        }
39 +
    }
40 +
}
41 +
42 +
/// Open a new file readable and writable by its owner only.
43 +
pub fn private(o: &mut OpenOptions) -> &mut OpenOptions {
44 +
    #[cfg(unix)]
45 +
    {
46 +
        use std::os::unix::fs::OpenOptionsExt;
47 +
        o.mode(0o600)
48 +
    }
49 +
    #[cfg(not(unix))]
50 +
    {
51 +
        o
52 +
    }
53 +
}
54 +
55 +
/// Make `p` and its parents, the last only its owner's.
56 +
pub fn create_private_dir(p: &Path) -> io::Result<()> {
57 +
    #[cfg(unix)]
58 +
    {
59 +
        std::os::unix::fs::DirBuilderExt::mode(std::fs::DirBuilder::new().recursive(true), 0o700).create(p)
60 +
    }
61 +
    #[cfg(not(unix))]
62 +
    {
63 +
        std::fs::create_dir_all(p)
64 +
    }
65 +
}
66 +
67 +
/// The disk a file takes: its blocks on Unix, its length elsewhere.
68 +
pub fn disk_bytes(md: &Metadata) -> u64 {
69 +
    #[cfg(unix)]
70 +
    {
71 +
        use std::os::unix::fs::MetadataExt;
72 +
        md.blocks() * 512
73 +
    }
74 +
    #[cfg(not(unix))]
75 +
    {
76 +
        md.len()
77 +
    }
78 +
}
79 +
80 +
/// For a file with more than one name, what names it once (device, inode);
81 +
/// `None` for a file with one name, a directory, or anywhere but Unix.
82 +
pub fn shared_identity(md: &Metadata) -> Option<(u64, u64)> {
83 +
    #[cfg(unix)]
84 +
    {
85 +
        use std::os::unix::fs::MetadataExt;
86 +
        (md.nlink() > 1 && !md.is_dir()).then(|| (md.dev(), md.ino()))
87 +
    }
88 +
    #[cfg(not(unix))]
89 +
    {
90 +
        let _ = md;
91 +
        None
92 +
    }
93 +
}
94 +
95 +
/// Make `link` a symbolic link to `target` (a file link on Windows, which
96 +
/// needs Developer Mode or the right to make them).
97 +
pub fn symlink(target: &Path, link: &Path) -> io::Result<()> {
98 +
    #[cfg(unix)]
99 +
    {
100 +
        std::os::unix::fs::symlink(target, link)
101 +
    }
102 +
    #[cfg(windows)]
103 +
    {
104 +
        std::os::windows::fs::symlink_file(target, link)
105 +
    }
106 +
}
added crates/flow/src/syscall_none.rs +45 −0
1 +
//! The syscall tier where it cannot be: it is built from Linux's user
2 +
//! namespaces, Landlock, seccomp and cgroups, so a runner elsewhere (a
3 +
//! Windows runner, oxygenflow#83) never offers it, and these say why.
4 +
5 +
use crate::home::Home;
6 +
use crate::microvm::{Outcome, Sink};
7 +
use anyhow::{bail, Result};
8 +
use flow_core::api::SecretInput;
9 +
use flow_core::{Job, Sandbox};
10 +
use std::path::Path;
11 +
use std::time::Duration;
12 +
13 +
pub use crate::tier::disk_dir;
14 +
pub(crate) use crate::tier::{lines, self_hash, FAILED};
15 +
16 +
const WHY: &str = "the syscall tier runs only on Linux";
17 +
18 +
pub fn available() -> std::result::Result<(), String> {
19 +
    Err(WHY.into())
20 +
}
21 +
22 +
pub fn probe_main() -> i32 {
23 +
    eprintln!("{WHY}");
24 +
    1
25 +
}
26 +
27 +
pub fn sandbox_main(_spec_path: &Path) -> i32 {
28 +
    eprintln!("{WHY}");
29 +
    125
30 +
}
31 +
32 +
#[allow(clippy::too_many_arguments)]
33 +
pub fn run(
34 +
    _home: &Home,
35 +
    _sandbox: &Sandbox,
36 +
    _job: &Job,
37 +
    _secrets: &[SecretInput],
38 +
    _workspace: &Path,
39 +
    _scratch: &Path,
40 +
    _timeout: Duration,
41 +
    _sink: &mut dyn Sink,
42 +
    _name: &str,
43 +
) -> Result<Outcome> {
44 +
    bail!("{WHY}")
45 +
}
added crates/flow/src/tier.rs +48 −0
1 +
//! What the sandbox tiers share, on every system a runner runs on: where a
2 +
//! job's disks are kept, how a helper says a job never started, reading a
3 +
//! job's output by lines, and this binary's hash for receipts.
4 +
5 +
use std::io::{BufRead, BufReader, Read};
6 +
use std::path::{Path, PathBuf};
7 +
use std::sync::{mpsc, OnceLock};
8 +
9 +
/// Where the `n`th `ephemeral://disk` mount of a job keeps its files.
10 +
pub fn disk_dir(scratch: &Path, n: usize) -> PathBuf {
11 +
    scratch.join(format!("mount-{n}"))
12 +
}
13 +
14 +
/// How the helper says the job never started, on its stderr.
15 +
pub(crate) const FAILED: &str = "\u{1e}F ";
16 +
17 +
pub(crate) fn lines(r: impl Read, stdout: bool, tx: mpsc::Sender<(bool, Vec<u8>)>) {
18 +
    let mut r = BufReader::new(r);
19 +
    let mut buf = Vec::new();
20 +
    loop {
21 +
        buf.clear();
22 +
        // A long line is cut at 16 KiB, as flow-init cuts it.
23 +
        match (&mut r).take(16 * 1024).read_until(b'\n', &mut buf) {
24 +
            Ok(0) | Err(_) => break,
25 +
            Ok(_) => {
26 +
                if buf.last() == Some(&b'\n') {
27 +
                    buf.pop();
28 +
                }
29 +
                if tx.send((stdout, buf.clone())).is_err() {
30 +
                    break;
31 +
                }
32 +
            }
33 +
        }
34 +
    }
35 +
}
36 +
37 +
/// This binary's hash, which receipts name as the sandbox.
38 +
pub(crate) fn self_hash() -> &'static str {
39 +
    static HASH: OnceLock<String> = OnceLock::new();
40 +
    HASH.get_or_init(|| {
41 +
        let exe = if cfg!(target_os = "linux") || cfg!(target_os = "android") {
42 +
            PathBuf::from("/proc/self/exe")
43 +
        } else {
44 +
            std::env::current_exe().unwrap_or_default()
45 +
        };
46 +
        crate::home::hash_file(&exe).unwrap_or_else(|_| "unknown".into())
47 +
    })
48 +
}
modified CHANGELOG.md +1 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **`flow` builds for Windows (#83, step 2 begun):** `cargo build --target x86_64-pc-windows-gnu` gives a `flow.exe` that offers the WASM tier: the syscall tier, the guest helpers and the egress proxy are Linux's only (a Windows build says the syscall tier "runs only on Linux"), cache pruning by reads stays on Linux, and file modes, links and sizes go through a small `portable` module (modes are not kept on Windows). Free disk, load and kernel are not measured there yet, and a Windows runner does not update itself. Nothing changes on Linux or Android.
7 8
- **The syscall tier's loopback reaches TCP (#73):** with `network "loopback"`, a job may open IPv4 and IPv6 sockets, which in its own network namespace reach only its `lo`, so a server it starts on 127.0.0.1 answers it (a conformance suite over an emulator's HTTP control port, which skipped every test on narvi-1). With `network "isolated"` sockets other than `AF_UNIX` are still refused.
8 9
- **Runner builds in releases (oxygenhub#322):** a `dist` job (`scripts/dist.sh`, on a change to `Cargo.toml` or the script) builds `flow` stripped for Linux x86_64, aarch64 and riscv64 (glibc 2.28 and later) as `flow-linux-ARCH`, named by the architecture a runner reports, with `SHA256SUMS`: the files a release carries for runners that update themselves.
9 10
- **Runners ask the hub for their update (oxygenhub#322):** once the hub has answered a claim, and every 15 minutes, a runner asks `GET /api/v1/flow/runner/update`; when the hub offers a build, it downloads it (kept in the home's `updates/`), refuses it unless its size and BLAKE3 are those the hub attests, and installs it as `flow runner update` does, leaving within 600 s, or for a dry run checks it and says what would happen. What it did goes back to the hub once per outcome, and a proof or a rollback is told at the next start. `[runner.update] allow = false` refuses and says so; a hub without runner updates (404) is simply asked again later.
…
99 unchanged lines
modified crates/flow-init/src/prune.rs +37 −2
…
29 unchanged lines
30 30
//! only if an older lockfile comes back, and then cargo rebuilds it.
31 31
32 32
use serde::{Deserialize, Serialize};
33 +
#[cfg(unix)]
33 34
use std::collections::{HashMap, HashSet};
35 +
#[cfg(unix)]
34 36
use std::os::unix::fs::MetadataExt;
35 -
use std::path::{Path, PathBuf};
37 +
use std::path::Path;
38 +
#[cfg(unix)]
39 +
use std::path::PathBuf;
36 40
use std::time::{Duration, SystemTime};
37 41
38 42
/// The share of a cache disk a kept cache may fill when no runner says
…
45 unchanged lines
84 88
    t
85 89
}
86 90
91 +
#[cfg(unix)]
87 92
/// Whether the file system holding `path` records reads (not `noatime`).
88 93
pub fn records_reads(path: &Path) -> bool {
89 94
    let Ok(c) = std::ffi::CString::new(path.as_os_str().as_encoded_bytes()) else { return false };
…
4 unchanged lines
94 99
    st.f_flag & libc::ST_NOATIME == 0
95 100
}
96 101
102 +
#[cfg(unix)]
97 103
/// The blocks in use on the file system mounted at `path`, and its size
98 104
/// (as `df` counts them).
99 105
pub fn usage(path: &Path) -> std::io::Result<(u64, u64)> {
…
6 unchanged lines
106 112
    Ok(((st.f_blocks as u64).saturating_sub(st.f_bfree as u64) * unit, st.f_blocks as u64 * unit))
107 113
}
108 114
115 +
#[cfg(unix)]
109 116
/// Set every regular file's atime under `root` to the epoch, its mtime
110 117
/// kept, so the job's first read of each is recorded even on `relatime`
111 118
/// (which records a read only of a file read no later than it changed).
…
19 unchanged lines
131 138
    }
132 139
}
133 140
141 +
#[cfg(unix)]
134 142
/// Each entry under `root` (not `root` itself) with its metadata, parents
135 143
/// before children; links are not followed, nor other file systems
136 144
/// entered.
…
17 unchanged lines
154 162
    Ok(())
155 163
}
156 164
165 +
#[cfg(unix)]
157 166
/// The unit hashes in `rel`: a 16-digit lowercase hex word after a `-`
158 167
/// in any of its names, as cargo names a unit's files.
159 168
fn hashes(rel: &Path) -> impl Iterator<Item = String> + '_ {
…
5 unchanged lines
165 174
    })
166 175
}
167 176
177 +
#[cfg(unix)]
168 178
fn nanos(s: i64, ns: i64) -> i128 {
169 179
    s as i128 * 1_000_000_000 + ns as i128
170 180
}
171 181
182 +
#[cfg(unix)]
172 183
/// One file, however many names it has.
173 184
struct File {
174 185
    paths: Vec<PathBuf>,
…
5 unchanged lines
180 191
    used: bool,
181 192
}
182 193
194 +
#[cfg(unix)]
183 195
/// Prune the cache at `root`, a disk of `size` bytes with `before` in
184 196
/// use, down to `percent` of its size, removing only regular files this
185 197
/// run did not use (see the module's notes), those used longest ago
…
75 unchanged lines
261 273
    Ok(out)
262 274
}
263 275
264 -
#[cfg(test)]
276 +
// Elsewhere (a Windows runner, which has no microVM tier and so no cache
277 +
// disks to prune) nothing records reads, so nothing is pruned.
278 +
279 +
#[cfg(not(unix))]
280 +
pub fn records_reads(_path: &Path) -> bool {
281 +
    false
282 +
}
283 +
284 +
#[cfg(not(unix))]
285 +
pub fn usage(_path: &Path) -> std::io::Result<(u64, u64)> {
286 +
    Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "cache disks are pruned only on Linux"))
287 +
}
288 +
289 +
#[cfg(not(unix))]
290 +
pub fn forget_reads(_root: &Path) -> std::io::Result<u64> {
291 +
    Ok(0)
292 +
}
293 +
294 +
#[cfg(not(unix))]
295 +
pub fn prune(_root: &Path, _started: SystemTime, _keep: &[String], _size: u64, _before: u64, _percent: u8) -> std::io::Result<Pruned> {
296 +
    Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "cache disks are pruned only on Linux"))
297 +
}
298 +
299 +
#[cfg(all(test, unix))]
265 300
mod tests {
266 301
    use super::*;
267 302
…
98 unchanged lines
modified crates/flow/src/cache.rs +12 −11
…
46 unchanged lines
47 47
use anyhow::{Context, Result};
48 48
use flow_core::pipeline::Mount;
49 49
use flow_init::prune::Pruned;
50 -
use std::os::unix::fs::{MetadataExt, PermissionsExt};
51 50
use std::path::{Path, PathBuf};
52 51
use std::time::SystemTime;
53 52
…
183 unchanged lines
237 236
            seal(disk).with_context(|| format!("seal {}", disk.display()))?;
238 237
        }
239 238
        let mine = self.cache_dir(ctx, name).join(segment(ctx.bookmark));
240 -
        std::os::unix::fs::DirBuilderExt::mode(std::fs::DirBuilder::new().recursive(true), 0o700).create(&mine)?;
241 -
        std::fs::set_permissions(&self.root, std::fs::Permissions::from_mode(0o700))?;
239 +
        crate::portable::create_private_dir(&mine)?;
240 +
        crate::portable::set_mode(&self.root, 0o700)?;
242 241
        let dest = mine.join(format!("{key}.{}", ctx.form.ext()));
243 242
        if ctx.form == Form::Dir {
244 243
            // Another job of this slot kept one since: this one is newer.
…
9 unchanged lines
254 253
        let _ = std::fs::remove_file(&tmp);
255 254
        {
256 255
            use std::io::Write;
257 -
            use std::os::unix::fs::OpenOptionsExt;
258 -
            std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(&tmp)?.write_all(&serde_json::to_vec(sources)?)?;
256 +
            crate::portable::private(std::fs::OpenOptions::new().write(true).create_new(true))
257 +
                .open(&tmp)?
258 +
                .write_all(&serde_json::to_vec(sources)?)?;
259 259
        }
260 260
        std::fs::rename(&tmp, sidecar)?;
261 261
        // One disk per key is enough; older keys of this bookmark go once
…
66 unchanged lines
328 328
        kept(&self.root, 4, &mut |p, form| {
329 329
            if let Ok(md) = std::fs::symlink_metadata(p) {
330 330
                let bytes = match form {
331 -
                    Form::Image => md.blocks() * 512,
331 +
                    Form::Image => crate::portable::disk_bytes(&md),
332 332
                    Form::Dir => tree_bytes(p),
333 333
                };
334 334
                disks.push((md.modified().unwrap_or(SystemTime::UNIX_EPOCH), bytes, p.to_path_buf()));
…
146 unchanged lines
481 481
fn tree_bytes(dir: &Path) -> u64 {
482 482
    fn walk(p: &Path, seen: &mut std::collections::HashSet<(u64, u64)>) -> u64 {
483 483
        let Ok(md) = std::fs::symlink_metadata(p) else { return 0 };
484 -
        if md.nlink() > 1 && !md.is_dir() && !seen.insert((md.dev(), md.ino())) {
484 +
        if crate::portable::shared_identity(&md).is_some_and(|id| !seen.insert(id)) {
485 485
            return 0;
486 486
        }
487 -
        let mut bytes = md.blocks() * 512;
487 +
        let mut bytes = crate::portable::disk_bytes(&md);
488 488
        if md.is_dir() {
489 489
            for e in std::fs::read_dir(p).into_iter().flatten().flatten() {
490 490
                bytes += walk(&e.path(), seen);
…
14 unchanged lines
505 505
    if md.file_type().is_symlink() {
506 506
        return Ok(());
507 507
    }
508 -
    let mode = md.mode() & 0o7777;
508 +
    let mode = crate::portable::mode(&md);
509 509
    let want = (mode & !0o7022) | if md.is_dir() { 0o700 } else { 0o400 };
510 510
    if want != mode {
511 -
        std::fs::set_permissions(p, std::fs::Permissions::from_mode(want))?;
511 +
        crate::portable::set_mode(p, want)?;
512 512
    }
513 513
    if md.is_dir() {
514 514
        for e in std::fs::read_dir(p)? {
…
43 unchanged lines
558 558
    Ok(())
559 559
}
560 560
561 -
#[cfg(test)]
561 +
#[cfg(all(test, unix))]
562 562
mod tests {
563 563
    use super::*;
564 564
    use flow_core::pipeline::MountSource;
565 +
    use std::os::unix::fs::{MetadataExt, PermissionsExt};
565 566
566 567
    /// Pruning that never prunes: the cap is the whole disk.
567 568
    fn keep_all() -> Prune<'static> {
…
397 unchanged lines
modified crates/flow/src/checkpoint.rs +2 −4
…
92 unchanged lines
93 93
}
94 94
95 95
fn entry(out: &mut Vec<u8>, kind: u8, path: &str, meta: &std::fs::Metadata, bytes: &[u8]) {
96 -
    use std::os::unix::fs::PermissionsExt;
97 96
    let since = meta.modified().ok().and_then(|t| t.duration_since(UNIX_EPOCH).ok()).unwrap_or_default();
98 97
    out.push(kind);
99 98
    out.extend((path.len() as u32).to_le_bytes());
100 99
    out.extend(path.as_bytes());
101 -
    out.extend(meta.permissions().mode().to_le_bytes());
100 +
    out.extend(crate::portable::mode(meta).to_le_bytes());
102 101
    out.extend(since.as_secs().to_le_bytes());
103 102
    out.extend(since.subsec_nanos().to_le_bytes());
104 103
    out.extend((bytes.len() as u64).to_le_bytes());
…
3 unchanged lines
108 107
/// Restore a checkpoint: each entry at `place(checkpoint)` joined with the
109 108
/// rest of its path, for the job's own `checkpoints` only.
110 109
pub fn unpack(bytes: &[u8], checkpoints: &[String], place: impl Fn(&str) -> Option<PathBuf>) -> Result<Counts> {
111 -
    use std::os::unix::fs::PermissionsExt;
112 110
    let Some(mut rest) = bytes.strip_prefix(MAGIC) else { bail!("not a checkpoint") };
113 111
    let mut n = Counts::default();
114 112
    let take = |rest: &mut &[u8], k: usize| -> Result<Vec<u8>> {
…
39 unchanged lines
154 152
                }
155 153
                std::fs::write(&at, &data)?;
156 154
                let f = std::fs::File::options().write(true).open(&at)?;
157 -
                f.set_permissions(std::fs::Permissions::from_mode(mode & 0o777))?;
155 +
                crate::portable::set_mode(&at, mode & 0o777)?;
158 156
                f.set_modified(SystemTime::UNIX_EPOCH + Duration::new(secs, nanos))?;
159 157
                n.files += 1;
160 158
                n.bytes += data.len() as u64;
…
54 unchanged lines
modified crates/flow/src/egress.rs +30 −3
…
6 unchanged lines
7 7
//! decision goes into the job log, and the bytes are relayed untouched (no
8 8
//! TLS is opened here).
9 9
10 +
#[cfg(unix)]
10 11
use std::io::{Read, Write};
11 -
use std::net::{IpAddr, SocketAddr, TcpStream, ToSocketAddrs};
12 +
use std::net::IpAddr;
13 +
#[cfg(unix)]
14 +
use std::net::{SocketAddr, TcpStream, ToSocketAddrs};
15 +
#[cfg(unix)]
12 16
use std::os::unix::net::{UnixListener, UnixStream};
13 17
use std::path::Path;
18 +
#[cfg(unix)]
14 19
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
15 -
use std::sync::{mpsc, Arc};
20 +
use std::sync::mpsc;
21 +
#[cfg(unix)]
22 +
use std::sync::Arc;
23 +
#[cfg(unix)]
16 24
use std::time::Duration;
17 25
18 26
/// The vsock port the guest's relay connects to on the host (CID 2).
…
2 unchanged lines
21 29
pub const GUEST_PROXY: &str = "http://127.0.0.1:3128";
22 30
/// The only port relayed: TLS.
23 31
const PORT: u16 = 443;
32 +
#[cfg(unix)]
24 33
/// Connections at once, per job.
25 34
const MAX_OPEN: usize = 64;
35 +
#[cfg(unix)]
26 36
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
37 +
#[cfg(unix)]
27 38
/// The longest request head read.
28 39
const MAX_HEAD: usize = 8 * 1024;
29 40
…
74 unchanged lines
104 115
    }
105 116
}
106 117
118 +
#[cfg(unix)]
107 119
/// The proxy for one job: listening until dropped.
108 120
pub struct Proxy {
109 121
    stop: Arc<AtomicBool>,
110 122
    path: std::path::PathBuf,
111 123
}
112 124
125 +
#[cfg(unix)]
113 126
impl Drop for Proxy {
114 127
    fn drop(&mut self) {
115 128
        self.stop.store(true, Ordering::SeqCst);
…
3 unchanged lines
119 132
    }
120 133
}
121 134
135 +
#[cfg(unix)]
122 136
/// Listen on `socket` (Firecracker's `uds_path` and `_3128`) for the
123 137
/// guest's connections; each decision is sent to `log` as a line.
124 138
pub fn start(socket: &Path, allow: Vec<String>, log: mpsc::Sender<String>) -> std::io::Result<Proxy> {
…
25 unchanged lines
150 164
    Ok(Proxy { stop, path: socket.to_path_buf() })
151 165
}
152 166
167 +
#[cfg(unix)]
153 168
fn refuse(mut conn: UnixStream, why: &str) -> std::io::Result<()> {
154 169
    let body = format!("{why}\n");
155 170
    write!(conn, "HTTP/1.1 403 Forbidden\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len())
156 171
}
157 172
173 +
#[cfg(unix)]
158 174
/// Read up to the blank line that ends a request head.
159 175
fn read_head(conn: &mut UnixStream) -> std::io::Result<String> {
160 176
    let mut head = Vec::new();
…
7 unchanged lines
168 184
    Ok(String::from_utf8_lossy(&head).into_owned())
169 185
}
170 186
187 +
#[cfg(unix)]
171 188
fn serve(mut conn: UnixStream, allow: &[String], log: &mpsc::Sender<String>) {
172 189
    let _ = conn.set_read_timeout(Some(Duration::from_secs(30)));
173 190
    let head = match read_head(&mut conn) {
…
41 unchanged lines
215 232
    relay(conn, out);
216 233
}
217 234
235 +
#[cfg(unix)]
218 236
/// Copy both ways until either side closes.
219 237
fn relay(guest: UnixStream, out: TcpStream) {
220 238
    let (Ok(mut guest_r), Ok(mut out_w)) = (guest.try_clone(), out.try_clone()) else { return };
…
7 unchanged lines
228 246
    let _ = up.join();
229 247
}
230 248
231 -
#[cfg(test)]
249 +
/// The egress proxy serves a microVM's vsock, which only Linux has.
250 +
#[cfg(not(unix))]
251 +
pub struct Proxy;
252 +
253 +
#[cfg(not(unix))]
254 +
pub fn start(_socket: &Path, _allow: Vec<String>, _log: mpsc::Sender<String>) -> std::io::Result<Proxy> {
255 +
    Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "egress serves microVMs, which run only on Linux"))
256 +
}
257 +
258 +
#[cfg(all(test, unix))]
232 259
mod tests {
233 260
    use super::*;
234 261
…
67 unchanged lines
modified crates/flow/src/facts.rs +24 −4
…
22 unchanged lines
23 23
24 24
/// The kernel's release, as uname says. Not read from /proc: an Android
25 25
/// app may not read it there, and is refused (and logged) every claim.
26 +
/// None on Windows (oxygenflow#83).
27 +
#[cfg(unix)]
26 28
pub fn kernel() -> Option<String> {
27 29
    let mut u: libc::utsname = unsafe { std::mem::zeroed() };
28 30
    // SAFETY: `u` is a writable utsname.
…
5 unchanged lines
34 36
    Some(release.to_string_lossy().into_owned())
35 37
}
36 38
37 -
/// Free space for an unprivileged writer on the file system holding `path`, in MiB.
39 +
#[cfg(not(unix))]
40 +
pub fn kernel() -> Option<String> {
41 +
    None
42 +
}
43 +
44 +
/// Free space for an unprivileged writer on the file system holding `path`,
45 +
/// in MiB; not measured on Windows.
46 +
#[cfg(unix)]
38 47
pub fn free_disk_mib(path: &std::path::Path) -> Option<u64> {
39 48
    use std::os::unix::ffi::OsStrExt;
40 49
    let c = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?;
…
3 unchanged lines
44 53
        return None;
45 54
    }
46 55
    Some((s.f_bavail as u64).saturating_mul(s.f_frsize as u64) >> 20)
56 +
}
57 +
58 +
#[cfg(not(unix))]
59 +
pub fn free_disk_mib(_path: &std::path::Path) -> Option<u64> {
60 +
    None
47 61
}
48 62
49 63
/// The disk the files under `dir` take (their blocks, not their sizes: a
50 64
/// job's disks are sparse), in MiB; links are not followed.
51 65
pub fn allocated_mib(dir: &std::path::Path) -> Option<u64> {
52 -
    use std::os::unix::fs::MetadataExt;
53 66
    let mut total: u64 = 0;
54 67
    let mut stack = vec![dir.to_path_buf()];
55 68
    while let Some(d) = stack.pop() {
…
2 unchanged lines
58 71
            if m.is_dir() {
59 72
                stack.push(e.path());
60 73
            } else {
61 -
                total = total.saturating_add(m.blocks() * 512);
74 +
                total = total.saturating_add(crate::portable::disk_bytes(&m));
62 75
            }
63 76
        }
64 77
    }
…
8 unchanged lines
73 86
}
74 87
75 88
/// The 1-minute load average, times 100, from sysinfo (the kernel's own
76 -
/// numbers behind /proc/loadavg, which an Android app may not read).
89 +
/// numbers behind /proc/loadavg, which an Android app may not read); not
90 +
/// measured on Windows.
91 +
#[cfg(not(unix))]
92 +
pub fn load_x100() -> Option<u32> {
93 +
    None
94 +
}
95 +
96 +
#[cfg(unix)]
77 97
pub fn load_x100() -> Option<u32> {
78 98
    let mut i: libc::sysinfo = unsafe { std::mem::zeroed() };
79 99
    // SAFETY: `i` is a writable sysinfo.
…
57 unchanged lines
modified crates/flow/src/home.rs +1 −2
…
272 unchanged lines
273 273
        let tmp = self.root.join("runner.key.tmp");
274 274
        {
275 275
            use std::io::Write;
276 -
            use std::os::unix::fs::OpenOptionsExt;
277 -
            let mut f = std::fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).open(&tmp)?;
276 +
            let mut f = crate::portable::private(std::fs::OpenOptions::new().write(true).create(true).truncate(true)).open(&tmp)?;
278 277
            f.write_all(&secret.encode(&identity.hash()))?;
279 278
            f.sync_all()?;
280 279
        }
…
24 unchanged lines
modified crates/flow/src/lib.rs +7 −0
…
8 unchanged lines
9 9
pub mod facts;
10 10
/// The job-starting code `flow-init` uses in a microVM, shared with the
11 11
/// syscall tier.
12 +
#[cfg(unix)]
12 13
#[path = "../../flow-init/src/job.rs"]
13 14
pub mod guest;
14 15
pub mod home;
…
1 unchanged lines
16 17
pub mod metrics;
17 18
pub mod microvm;
18 19
pub mod outputs;
20 +
pub mod portable;
19 21
pub mod runner;
20 22
pub mod swarm;
23 +
#[cfg(unix)]
21 24
pub mod syscall;
25 +
#[cfg(not(unix))]
26 +
#[path = "syscall_none.rs"]
27 +
pub mod syscall;
28 +
pub mod tier;
22 29
pub mod update;
23 30
pub mod wait;
24 31
pub mod wasm;
…
1 unchanged lines
modified crates/flow/src/main.rs +8 −3
…
308 unchanged lines
309 309
            // A new build on trial that keeps failing to start goes back to
310 310
            // the one before, which then runs in its place (#85).
311 311
            if let flow::update::Start::RolledBack(p) = flow::update::on_start(&home.root)? {
312 -
                use std::os::unix::process::CommandExt;
313 -
                let err = std::process::Command::new(&p.exe).args(std::env::args_os().skip(1)).exec();
314 -
                bail!("starting flow {} again after the rollback: {err}", p.from);
312 +
                #[cfg(unix)]
313 +
                {
314 +
                    use std::os::unix::process::CommandExt;
315 +
                    let err = std::process::Command::new(&p.exe).args(std::env::args_os().skip(1)).exec();
316 +
                    bail!("starting flow {} again after the rollback: {err}", p.from);
317 +
                }
318 +
                #[cfg(not(unix))]
319 +
                bail!("rolled back to flow {}; start the runner again", p.from);
315 320
            }
316 321
            // The runner's oxy state (identity, user config) stays in its home.
317 322
            if std::env::var_os("OXY_HOME").is_none() {
…
295 unchanged lines
modified crates/flow/src/outputs.rs +40 −6
…
34 unchanged lines
35 35
/// [`MAX_ARTIFACT_BYTES`]. The job wrote that tree, so no link is
36 36
/// followed on the way: each directory is opened with `O_NOFOLLOW`, and
37 37
/// so is the file.
38 +
#[cfg(unix)]
38 39
pub fn read_dir_file(base: &Path, inner: &str) -> Result<Vec<u8>> {
39 40
    use std::ffi::CString;
40 -
    use std::io::Read;
41 41
    use std::os::fd::FromRawFd;
42 -
    let parts: Vec<&str> = inner.split('/').filter(|p| !p.is_empty()).collect();
43 -
    if parts.is_empty() || parts.iter().any(|p| *p == "." || *p == "..") {
44 -
        bail!("{inner} is not a clean path");
45 -
    }
42 +
    let parts = clean_parts(inner)?;
46 43
    let c = |s: &str| CString::new(s).context("a path holds NUL");
47 44
    let mut dir = unsafe { libc::open(c(&base.to_string_lossy())?.as_ptr(), libc::O_DIRECTORY | libc::O_RDONLY | libc::O_CLOEXEC) };
48 45
    if dir < 0 {
…
14 unchanged lines
63 60
    unsafe { libc::close(dir) };
64 61
    if fd < 0 {
65 62
        bail!("{inner} is not a regular file in the job's files ({err})");
63 +
    }
64 +
    read_open(unsafe { std::fs::File::from_raw_fd(fd) }, inner)
65 +
}
66 +
67 +
/// [`read_dir_file`] on Windows (oxygenflow#83), where a WASM job's files
68 +
/// are a directory: each step is checked to be a directory and not a link
69 +
/// (nor a junction), then the file to be a regular file, before it is read.
70 +
#[cfg(not(unix))]
71 +
pub fn read_dir_file(base: &Path, inner: &str) -> Result<Vec<u8>> {
72 +
    let parts = clean_parts(inner)?;
73 +
    let mut at = base.to_path_buf();
74 +
    let (last, dirs) = parts.split_last().unwrap();
75 +
    for d in dirs {
76 +
        at.push(d);
77 +
        let md = std::fs::symlink_metadata(&at).with_context(|| format!("{inner}: {d} is not in the job's files"))?;
78 +
        if !md.is_dir() || md.file_type().is_symlink() {
79 +
            bail!("{inner}: {d} is not a directory in the job's files");
80 +
        }
66 81
    }
67 -
    let file = unsafe { std::fs::File::from_raw_fd(fd) };
82 +
    at.push(last);
83 +
    let md = std::fs::symlink_metadata(&at).with_context(|| format!("{inner} is not in the job's files"))?;
84 +
    if !md.is_file() || md.file_type().is_symlink() {
85 +
        bail!("{inner} is not a regular file in the job's files (it may be missing or a link)");
86 +
    }
87 +
    read_open(std::fs::File::open(&at)?, inner)
88 +
}
89 +
90 +
/// `inner`'s parts, refusing an empty path, `.` and `..`.
91 +
fn clean_parts(inner: &str) -> Result<Vec<&str>> {
92 +
    let parts: Vec<&str> = inner.split('/').filter(|p| !p.is_empty()).collect();
93 +
    if parts.is_empty() || parts.iter().any(|p| *p == "." || *p == "..") {
94 +
        bail!("{inner} is not a clean path");
95 +
    }
96 +
    Ok(parts)
97 +
}
98 +
99 +
/// Read an opened artifact file, at most [`MAX_ARTIFACT_BYTES`].
100 +
fn read_open(file: std::fs::File, inner: &str) -> Result<Vec<u8>> {
101 +
    use std::io::Read;
68 102
    let meta = file.metadata()?;
69 103
    if !meta.is_file() {
70 104
        bail!("{inner} is not a regular file in the job's files (it may be missing or a link)");
…
248 unchanged lines
modified crates/flow/src/runner.rs +7 −4
…
406 unchanged lines
407 407
    let path = home.root.join(crate::home::RUNNER_TOML);
408 408
    {
409 409
        use std::io::Write;
410 -
        use std::os::unix::fs::OpenOptionsExt;
411 410
        let tmp = path.with_extension("toml.tmp");
412 -
        let mut f = std::fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).open(&tmp)?;
411 +
        let mut f = crate::portable::private(std::fs::OpenOptions::new().write(true).create(true).truncate(true)).open(&tmp)?;
413 412
        f.write_all(text.as_bytes())?;
414 413
        f.sync_all()?;
415 414
        std::fs::rename(&tmp, &path)?;
…
247 unchanged lines
663 662
    crate::update::note_runner(&home.root);
664 663
    // `flow runner update` swapped a new build in and asks this one to
665 664
    // leave with the notice it gave (#85).
666 -
    let handler = on_update as extern "C" fn(libc::c_int) as libc::sighandler_t;
667 -
    unsafe { libc::signal(libc::SIGUSR1, handler) };
665 +
    #[cfg(unix)]
666 +
    {
667 +
        let handler = on_update as extern "C" fn(libc::c_int) as libc::sighandler_t;
668 +
        unsafe { libc::signal(libc::SIGUSR1, handler) };
669 +
    }
668 670
    if let Some(p) = crate::update::pending(&home.root) {
669 671
        // On trial: a build that does not reach the hub in time goes back.
670 672
        let root = home.root.clone();
…
15 unchanged lines
686 688
/// Set by SIGUSR1: a new build is in place; leave for it.
687 689
static UPDATE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
688 690
691 +
#[cfg(unix)]
689 692
extern "C" fn on_update(_: libc::c_int) {
690 693
    UPDATE.store(true, std::sync::atomic::Ordering::SeqCst);
691 694
}
…
1551 unchanged lines
modified crates/flow/src/swarm.rs +1 −2
…
502 unchanged lines
503 503
    let made = rcgen::generate_simple_self_signed(vec!["flow-swarm".to_string()]).context("make the swarm's certificate")?;
504 504
    let (cert, key) = (made.cert.der().to_vec(), made.signing_key.serialize_der());
505 505
    {
506 -
        use std::os::unix::fs::OpenOptionsExt;
507 -
        let mut f = std::fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).open(&key_path)?;
506 +
        let mut f = crate::portable::private(std::fs::OpenOptions::new().write(true).create(true).truncate(true)).open(&key_path)?;
508 507
        f.write_all(&key)?;
509 508
    }
510 509
    std::fs::write(&cert_path, &cert)?;
…
473 unchanged lines
modified crates/flow/src/syscall.rs +2 −34
…
19 unchanged lines
20 20
use crate::guest::{dev_links, job_env, loopback_up, resolve_program, PATH};
21 21
use crate::home::Home;
22 22
use crate::microvm::{Outcome, Sink};
23 +
pub use crate::tier::disk_dir;
24 +
pub(crate) use crate::tier::{lines, self_hash, FAILED};
23 25
use anyhow::{bail, Context, Result};
24 26
use flow_core::api::{Record, SecretInput};
25 27
use flow_core::{Environment, Job, MountSource, Network, Sandbox};
26 28
use serde::{Deserialize, Serialize};
27 29
use std::ffi::CString;
28 -
use std::io::{BufRead, BufReader, Read};
29 30
use std::path::{Path, PathBuf};
30 31
use std::sync::{mpsc, OnceLock};
31 32
use std::time::{Duration, Instant};
…
23 unchanged lines
55 56
    }
56 57
}
57 58
58 -
pub(crate) fn self_hash() -> &'static str {
59 -
    static HASH: OnceLock<String> = OnceLock::new();
60 -
    HASH.get_or_init(|| crate::home::hash_file(Path::new("/proc/self/exe")).unwrap_or_else(|_| "unknown".into()))
61 -
}
62 -
63 59
fn kernel_release() -> String {
64 60
    crate::facts::kernel().unwrap_or_else(|| "unknown".into())
65 61
}
…
223 unchanged lines
289 285
        peak_memory_mib,
290 286
        pruned: Vec::new(),
291 287
    })
292 -
}
293 -
294 -
/// Where the `n`th `ephemeral://disk` mount of a job keeps its files.
295 -
pub fn disk_dir(scratch: &Path, n: usize) -> PathBuf {
296 -
    scratch.join(format!("mount-{n}"))
297 -
}
298 -
299 -
/// How the helper says the job never started, on its stderr.
300 -
pub(crate) const FAILED: &str = "\u{1e}F ";
301 -
302 -
pub(crate) fn lines(r: impl Read, stdout: bool, tx: mpsc::Sender<(bool, Vec<u8>)>) {
303 -
    let mut r = BufReader::new(r);
304 -
    let mut buf = Vec::new();
305 -
    loop {
306 -
        buf.clear();
307 -
        // A long line is cut at 16 KiB, as flow-init cuts it.
308 -
        match (&mut r).take(16 * 1024).read_until(b'\n', &mut buf) {
309 -
            Ok(0) | Err(_) => break,
310 -
            Ok(_) => {
311 -
                if buf.last() == Some(&b'\n') {
312 -
                    buf.pop();
313 -
                }
314 -
                if tx.send((stdout, buf.clone())).is_err() {
315 -
                    break;
316 -
                }
317 -
            }
318 -
        }
319 -
    }
320 288
}
321 289
322 290
/// The image's tree, unpacked from its ext4 file once per hash with
…
584 unchanged lines
modified crates/flow/src/update.rs +16 −5
…
184 unchanged lines
185 185
/// tell the running runner (if any) to leave within `notice`, finishing
186 186
/// what fits and handing back the rest, so its unit starts the new build.
187 187
pub fn install(home: &crate::home::Home, exe: &Path, file: &Path, notice: Duration, dry_run: bool, allow_older: bool) -> Result<Installed> {
188 +
    if !cfg!(unix) {
189 +
        bail!("runners update themselves on Linux only, so far");
190 +
    }
188 191
    if home.config.runner.as_ref().and_then(|r| r.update.as_ref()).is_some_and(|u| !u.allow) {
189 192
        bail!("flow.toml says [runner.update] allow = false: this runner takes no updates");
190 193
    }
…
4 unchanged lines
195 198
    let staged = exe.with_file_name(NEW);
196 199
    let _ = std::fs::remove_file(&staged);
197 200
    std::fs::copy(file, &staged).with_context(|| format!("staging {} as {}", file.display(), staged.display()))?;
198 -
    {
199 -
        use std::os::unix::fs::PermissionsExt;
200 -
        std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755))?;
201 -
    }
201 +
    crate::portable::set_mode(&staged, 0o755)?;
202 202
    let checked = check(&staged, &home.root).and_then(|to| {
203 203
        if older(&to, &from) && !allow_older {
204 204
            bail!("flow {to} is older than this runner's {from} (--allow-older to go back)");
…
16 unchanged lines
221 221
        Pending { from: from.clone(), to: to.clone(), exe: exe.to_path_buf(), at_ms: now_ms(), notice_secs: notice.as_secs(), starts: 0 };
222 222
    save(&home.root, &p)?;
223 223
    log(&home.root, "installed", &from, &to, "flow runner update --from");
224 -
    let told = running(&home.root).filter(|pid| unsafe { libc::kill(*pid as libc::pid_t, libc::SIGUSR1) } == 0);
224 +
    let told = running(&home.root).filter(|pid| tell(*pid));
225 225
    Ok(Installed { from, to, exe: exe.to_path_buf(), told })
226 +
}
227 +
228 +
/// Ask runner `pid` to leave for the new build (SIGUSR1).
229 +
#[cfg(unix)]
230 +
fn tell(pid: u32) -> bool {
231 +
    unsafe { libc::kill(pid as libc::pid_t, libc::SIGUSR1) == 0 }
232 +
}
233 +
234 +
#[cfg(not(unix))]
235 +
fn tell(_pid: u32) -> bool {
236 +
    false
226 237
}
227 238
228 239
/// The running runner's process id, from its pid file, if that process is
…
165 unchanged lines
modified crates/flow/src/workspace.rs +18 −8
…
37 unchanged lines
38 38
                }
39 39
                if *kind == NodeKind::Symlink {
40 40
                    let to = String::from_utf8_lossy(&bytes).into_owned();
41 -
                    std::os::unix::fs::symlink(&to, &target).with_context(|| format!("symlink {path}"))?;
41 +
                    crate::portable::symlink(Path::new(&to), &target).with_context(|| format!("symlink {path}"))?;
42 42
                } else {
43 43
                    std::fs::write(&target, &bytes).with_context(|| format!("write {path}"))?;
44 44
                    if f.mode.exec {
45 -
                        use std::os::unix::fs::PermissionsExt;
46 -
                        std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755))?;
45 +
                        crate::portable::set_mode(&target, 0o755)?;
47 46
                    }
48 47
                }
49 48
                files.push((path.clone(), *blake3::hash(&bytes).as_bytes()));
…
29 unchanged lines
79 78
            std::fs::create_dir_all(&target)?;
80 79
        } else if ft.is_symlink() {
81 80
            let to = std::fs::read_link(entry.path())?;
82 -
            std::os::unix::fs::symlink(&to, &target)?;
81 +
            crate::portable::symlink(&to, &target)?;
83 82
            files.push((rel_s, *blake3::hash(to.to_string_lossy().as_bytes()).as_bytes()));
84 83
        } else if ft.is_file() {
85 84
            std::fs::copy(entry.path(), &target)?;
…
16 unchanged lines
102 101
        .map(|m| m.len().max(4096))
103 102
        .sum();
104 103
    let mib = used * 12 / 10 / (1 << 20) + 512;
105 -
    let (uid, gid) = unsafe { (libc::getuid(), libc::getgid()) };
104 +
    let (uid, gid) = owner();
106 105
    let status = std::process::Command::new("mkfs.ext4")
107 106
        .args(["-q", "-F", "-L", "workspace", "-E"])
108 107
        .arg(format!("root_owner={uid}:{gid}"))
…
46 unchanged lines
155 154
/// the workspace. mkfs.ext4 creates the file sparse, so only what the job
156 155
/// writes takes space on the runner.
157 156
pub fn empty_ext4(out: &Path, mib: u32, label: &str) -> Result<()> {
158 -
    let (uid, gid) = unsafe { (libc::getuid(), libc::getgid()) };
157 +
    let (uid, gid) = owner();
159 158
    let status = std::process::Command::new("mkfs.ext4")
160 159
        .args(["-q", "-F", "-L", label, "-E"])
161 160
        .arg(format!("root_owner={uid}:{gid}"))
…
20 unchanged lines
182 181
    }
183 182
}
184 183
184 +
/// The runner's user and group, which own a workspace image's root.
185 +
fn owner() -> (u32, u32) {
186 +
    #[cfg(unix)]
187 +
    {
188 +
        unsafe { (libc::getuid(), libc::getgid()) }
189 +
    }
190 +
    #[cfg(not(unix))]
191 +
    {
192 +
        (0, 0)
193 +
    }
194 +
}
195 +
185 196
fn open_up(dir: &Path) {
186 -
    use std::os::unix::fs::PermissionsExt;
187 197
    let Ok(meta) = std::fs::symlink_metadata(dir) else { return };
188 198
    if !meta.is_dir() {
189 199
        return;
190 200
    }
191 -
    let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
201 +
    let _ = crate::portable::set_mode(dir, 0o700);
192 202
    if let Ok(entries) = std::fs::read_dir(dir) {
193 203
        for e in entries.flatten() {
194 204
            open_up(&e.path());
…
43 unchanged lines