OXYGENHUB

Artifacts between jobs: input { artifact "x" from "job"; path "/src/x" } 46fe2c815cc3

flow-core parses inputs (from a job named in depends-on that declares
that artifact; clean paths under /src; at most 8; hashed as job field 10)
and the claim carries each with the blob the producer's receipt names. The
runner fetches each from /api/v1/flow/jobs/{job}/inputs/{index}, refuses
the job when its blob reference differs, writes it at its path (outside the
scope hash) and lists the blobs as the receipt's inputs. Spec 5.3.1. Tests:
parsing and every refusal (flow-core), placing and the mismatch (runner).

Refs #58
Author
Andy McCall @andymccall <[email protected]>
via
agent Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
When
· 2026-10-16 03:24 UTC
Signature
✔ signed by Claude Code
Hash
46fe2c815cc3fe5418562f0a5128d9e685148c29807881f49a587a5a6ea799ba
Depends on
1abadd4a2fa6 6d53fb7d00ec 720eb8191059 89171fca079c a083cc802892 a67b97e9a717 bb643c252515 bf7bb1b80fa3 c4285535b03a d60aac6c3f8a e0918b887850 e2c125610f43 e576e08617dc ea56c56eaf90 ea81796bb757 fc10d9e80383
Depended on by
0d663e91c598 f201e1bc5bcd 1b160d372d9e 2be1fe4ad345 f4e51e63582d e20897216221
In bookmarks
main
In tags
v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.5.0 v0.5.1 v0.5.2 v0.5.3
Edits
24 atoms, 0 tree operations, 8 files

Browse the tree at this change

modified crates/flow-core/src/api.rs +17 −0
…
254 unchanged lines
255 255
    /// them for the repository (oxygenhub#107).
256 256
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
257 257
    pub secrets: Vec<SecretInput>,
258 +
    /// The job's inputs (oxygenflow#58), in the job's order, each with the
259 +
    /// blob the producing job's receipt names: fetched from
260 +
    /// `/api/v1/flow/jobs/{job_id}/inputs/{index}` and checked against it.
261 +
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
262 +
    pub inputs: Vec<ArtifactInput>,
263 +
}
264 +
265 +
/// An artifact a job takes (oxygenflow#58), as the hub hands it over.
266 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
267 +
pub struct ArtifactInput {
268 +
    pub artifact: String,
269 +
    /// The producing job.
270 +
    pub from: String,
271 +
    /// Where it goes, under `/src`.
272 +
    pub path: String,
273 +
    /// The Oxygen blob reference its receipt names, hex.
274 +
    pub blob: String,
258 275
}
259 276
260 277
impl Claim {
…
78 unchanged lines
modified crates/flow-core/src/canonical.rs +7 −0
…
105 unchanged lines
106 106
    if !j.identities.is_empty() {
107 107
        fields.push((9, Value::Array(j.identities.iter().map(|i| Value::Array(vec![text(&i.audience), text(&i.mount_path)])).collect())));
108 108
    }
109 +
    // Inputs as [artifact, from, path] sorted by path; absent when none.
110 +
    if !j.inputs.is_empty() {
111 +
        fields.push((
112 +
            10,
113 +
            Value::Array(j.inputs.iter().map(|i| Value::Array(vec![text(&i.artifact), text(&i.from), text(&i.path)])).collect()),
114 +
        ));
115 +
    }
109 116
    Value::map(fields)
110 117
}
111 118
…
41 unchanged lines
modified crates/flow-core/src/lib.rs +2 −2
…
11 unchanged lines
12 12
13 13
pub use canonical::{object_hash, scope_tree_hash, Hash};
14 14
pub use pipeline::{
15 -
    arch_ok, egress_allows, egress_pattern_ok, Identity, Job, Mount, MountSource, Network, Output, OutputKind, Pipeline, Problem, Problems,
16 -
    Runtime, Sandbox, Secret, EGRESS_LABEL, PIPELINE_PATH,
15 +
    arch_ok, egress_allows, egress_pattern_ok, Identity, Input, Job, Mount, MountSource, Network, Output, OutputKind, Pipeline, Problem,
16 +
    Problems, Runtime, Sandbox, Secret, EGRESS_LABEL, PIPELINE_PATH,
17 17
};
18 18
pub use receipt::{Environment, Receipt};
19 19
…
2 unchanged lines
modified crates/flow-core/src/pipeline.rs +87 −0
…
171 unchanged lines
172 172
    /// Files the job produces, sorted by name (spec §5.2 `output`).
173 173
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
174 174
    pub outputs: Vec<Output>,
175 +
    /// Artifacts of jobs this one depends on, put in its workspace before it
176 +
    /// starts (oxygenflow#58), sorted by path. The runner checks each against
177 +
    /// the producing job's receipt; this job's receipt lists them as inputs.
178 +
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
179 +
    pub inputs: Vec<Input>,
175 180
    /// Secrets the job mounts, sorted by name (spec §9 `secret`): the hub
176 181
    /// supplies each by name, the runner mounts it as a file.
177 182
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
…
94 unchanged lines
272 277
    /// `report.toml`, kept file by file with a manifest (oxygenhub#127).
273 278
    #[serde(default, skip_serializing_if = "OutputKind::is_artifact")]
274 279
    pub kind: OutputKind,
280 +
}
281 +
282 +
/// An artifact a job takes from a job it depends on (oxygenflow#58):
283 +
/// `input { artifact "tfplan" from "plan"; path "/src/tfplan" }`.
284 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
285 +
pub struct Input {
286 +
    /// The producing job's artifact.
287 +
    pub artifact: String,
288 +
    /// The producing job: one this job depends on.
289 +
    pub from: String,
290 +
    /// Where it is put: a clean absolute path under `/src`.
291 +
    pub path: String,
275 292
}
293 +
294 +
/// The most inputs a job takes.
295 +
const MAX_INPUTS: usize = 8;
276 296
277 297
/// What an [`Output`] is.
278 298
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
…
300 unchanged lines
579 599
                    ctx.err(*span, format!("job {:?} depends on {:?}, which is not declared", job.name, d));
580 600
                } else if d == &job.name {
581 601
                    ctx.err(*span, format!("job {:?} depends on itself", job.name));
602 +
                }
603 +
            }
604 +
            // Inputs come from a job this one depends on, which declares
605 +
            // that artifact (oxygenflow#58).
606 +
            for i in &job.inputs {
607 +
                let Some((producer, _)) = jobs.get(&i.from) else {
608 +
                    ctx.err(*span, format!("job {:?} takes an input from {:?}, which is not declared", job.name, i.from));
609 +
                    continue;
610 +
                };
611 +
                if !job.depends_on.contains(&i.from) {
612 +
                    ctx.err(*span, format!("job {:?} takes {:?} from {:?}: add depends-on \"{}\"", job.name, i.artifact, i.from, i.from));
582 613
                }
614 +
                match producer.outputs.iter().find(|o| o.name == i.artifact) {
615 +
                    Some(o) if o.kind == OutputKind::Artifact => {}
616 +
                    Some(_) => ctx.err(*span, format!("{:?} of job {:?} is a report; inputs are artifacts", i.artifact, i.from)),
617 +
                    None => ctx.err(*span, format!("job {:?} declares no artifact {:?}", i.from, i.artifact)),
618 +
                }
583 619
            }
584 620
        }
585 621
        let graph: BTreeMap<&str, Vec<&str>> =
…
680 unchanged lines
1266 1302
            ctx.err(a.span(), format!("a job has at most {MAX_OUTPUTS} artifacts and reports"));
1267 1303
        } else {
1268 1304
            job.outputs.push(Output { name, path, always, kind });
1305 +
        }
1306 +
    }
1307 +
}
1308 +
1309 +
/// `input { artifact "NAME" from "JOB"; path "/src/FILE" }`.
1310 +
fn parse_input(ctx: &mut Ctx, node: &KdlNode, job: &mut Job) {
1311 +
    if !node.entries().is_empty() {
1312 +
        ctx.err(node.span(), "`input` takes a block: input { artifact \"tfplan\" from \"plan\"; path \"/src/tfplan\" }");
1313 +
    }
1314 +
    let (mut artifact, mut from, mut path) = (None, None, None);
1315 +
    for c in block(ctx, node) {
1316 +
        ctx.no_children(c);
1317 +
        match c.name().value() {
1318 +
            "artifact" => match ctx.strings(c).as_slice() {
1319 +
                [name, word, producer] if word == "from" => {
1320 +
                    if ctx.name_ok(c.span(), "artifact", name) && ctx.name_ok(c.span(), "job", producer) {
1321 +
                        artifact = Some(name.clone());
1322 +
                        from = Some(producer.clone());
1323 +
                    }
1324 +
                }
1325 +
                _ => ctx.err(c.span(), "`artifact` names the artifact and its job: artifact \"tfplan\" from \"plan\""),
1326 +
            },
1327 +
            "path" => match ctx.one_string(c) {
1328 +
                Some(p) if output_path_ok(&p) => path = Some(p),
1329 +
                Some(p) => {
1330 +
                    ctx.err(c.span(), format!("input path {p:?} must be an absolute path under /src, without . or .., quotes or spaces"))
1331 +
                }
1332 +
                None => {}
1333 +
            },
1334 +
            other => ctx.err(c.span(), format!("unknown input setting `{other}`; an input has `artifact` and `path`")),
1335 +
        }
1336 +
    }
1337 +
    match (artifact, from, path) {
1338 +
        (Some(artifact), Some(from), Some(path)) => {
1339 +
            if job.inputs.iter().any(|i| i.path == path) {
1340 +
                ctx.err(node.span(), format!("two inputs are put at {path:?}"));
1341 +
            } else if job.inputs.len() == MAX_INPUTS {
1342 +
                ctx.err(node.span(), format!("a job takes at most {MAX_INPUTS} inputs"));
1343 +
            } else {
1344 +
                job.inputs.push(Input { artifact, from, path });
1345 +
            }
1346 +
        }
1347 +
        (a, _, p) => {
1348 +
            if a.is_some() && p.is_none() {
1349 +
                ctx.err(node.span(), "an input needs a `path`");
1350 +
            } else if a.is_none() && p.is_some() {
1351 +
                ctx.err(node.span(), "an input needs `artifact \"NAME\" from \"JOB\"`");
1352 +
            }
1269 1353
        }
1270 1354
    }
1271 1355
}
…
12 unchanged lines
1284 1368
        secrets: vec![],
1285 1369
        identities: vec![],
1286 1370
        outputs: vec![],
1371 +
        inputs: vec![],
1287 1372
        runs_on: vec![],
1288 1373
        timeout_s: None,
1289 1374
        on: None,
…
71 unchanged lines
1361 1446
                outputs_at = Some(c.span());
1362 1447
                parse_output(ctx, c, &mut job);
1363 1448
            }
1449 +
            "input" => parse_input(ctx, c, &mut job),
1364 1450
            "secret" => parse_secret(ctx, c, &mut job),
1365 1451
            "identity" => parse_identity(ctx, c, &mut job),
1366 1452
            n if LATER_JOB.contains(&n) => ctx.err(c.span(), format!("`{n}` is not in M0 yet")),
…
2 unchanged lines
1369 1455
    }
1370 1456
    job.mounts.sort_by(|a, b| a.target.cmp(&b.target));
1371 1457
    job.outputs.sort_by(|a, b| a.name.cmp(&b.name));
1458 +
    job.inputs.sort_by(|a, b| a.path.cmp(&b.path));
1372 1459
    job.secrets.sort_by(|a, b| a.name.cmp(&b.name));
1373 1460
    job.identities.sort_by(|a, b| a.audience.cmp(&b.audience));
1374 1461
    // A tmpfs is gone when the job ends; nothing on it can be kept.
…
23 unchanged lines
modified crates/flow-core/src/tests.rs +31 −0
…
442 unchanged lines
443 443
        run_number: 3,
444 444
        env: [("CC".to_string(), "clang".to_string()), ("FLOW_REPO".to_string(), "spoofed".to_string())].into_iter().collect(),
445 445
        secrets: vec![],
446 +
        inputs: vec![],
446 447
    };
447 448
    let env = job.with_flow_env(&claim.flow_env()).env;
448 449
    assert_eq!(env["CC"], "clang", "the hub's variables reach the job");
…
189 unchanged lines
638 639
        let e = Pipeline::parse(&t).unwrap_err().to_string();
639 640
        assert!(e.contains(why), "{bad:?}: {e}");
640 641
    }
642 +
}
643 +
644 +
#[test]
645 +
fn a_job_takes_an_artifact_of_a_job_it_depends_on() {
646 +
    let jobs = |apply: &str| {
647 +
        doc(&format!(
648 +
            "job \"plan\" {{\n    sandbox \"alpine\"\n    output {{ artifact \"tfplan\" {{ path \"/src/out/tfplan\"; }}; report \"r\" {{ path \"/src/out/r\"; }}; }}\n    exec \"plan\"\n}}\njob \"apply\" {{\n    sandbox \"alpine\"\n{apply}    exec \"apply\"\n}}\n"
649 +
        ))
650 +
    };
651 +
    let ok = jobs("    depends-on \"plan\"\n    input { artifact \"tfplan\" from \"plan\"; path \"/src/tfplan\"; }\n");
652 +
    let p = Pipeline::parse(&ok).unwrap();
653 +
    assert_eq!(p.jobs["apply"].inputs, [crate::Input { artifact: "tfplan".into(), from: "plan".into(), path: "/src/tfplan".into() }]);
654 +
    let without = Pipeline::parse(&jobs("    depends-on \"plan\"\n")).unwrap();
655 +
    assert_ne!(p.hash(), without.hash(), "inputs are part of the job");
656 +
657 +
    let has = |apply: &str, want: &str| {
658 +
        let ps = problems(&jobs(apply));
659 +
        assert!(ps.iter().any(|(_, m)| m.contains(want)), "{apply}: {ps:?}");
660 +
    };
661 +
    has("    input { artifact \"tfplan\" from \"plan\"; path \"/src/tfplan\"; }\n", "add depends-on \"plan\"");
662 +
    has("    depends-on \"plan\"\n    input { artifact \"nope\" from \"plan\"; path \"/src/x\"; }\n", "declares no artifact \"nope\"");
663 +
    has("    depends-on \"plan\"\n    input { artifact \"r\" from \"plan\"; path \"/src/x\"; }\n", "is a report");
664 +
    has("    input { artifact \"tfplan\" from \"ghost\"; path \"/src/x\"; }\n", "which is not declared");
665 +
    has("    depends-on \"plan\"\n    input { artifact \"tfplan\" from \"plan\"; path \"/etc/x\"; }\n", "absolute path under /src");
666 +
    has("    depends-on \"plan\"\n    input { artifact \"tfplan\" \"plan\"; path \"/src/x\"; }\n", "artifact \"tfplan\" from \"plan\"");
667 +
    has("    depends-on \"plan\"\n    input { artifact \"tfplan\" from \"plan\"; }\n", "needs a `path`");
668 +
    has(
669 +
        "    depends-on \"plan\"\n    input { artifact \"tfplan\" from \"plan\"; path \"/src/x\"; }\n    input { artifact \"tfplan\" from \"plan\"; path \"/src/x\"; }\n",
670 +
        "two inputs are put at",
671 +
    );
641 672
}
modified crates/flow/src/outputs.rs +1 −0
…
223 unchanged lines
224 224
                Mount { source: MountSource::Disk, target: "/src/work".into(), size_mib: 1024, cache: None, key: vec![] },
225 225
            ],
226 226
            outputs: vec![],
227 +
            inputs: vec![],
227 228
            runs_on: vec![],
228 229
            timeout_s: None,
229 230
            on: None,
…
84 unchanged lines
modified crates/flow/src/runner.rs +80 −2
…
9 unchanged lines
10 10
use flow_core::{scope_tree_hash, Receipt};
11 11
use oxy_core::hash::ChangeHash;
12 12
use oxy_core::repo::Repo;
13 -
use std::path::PathBuf;
13 +
use std::path::{Path, PathBuf};
14 14
use std::time::{Duration, Instant};
15 15
16 16
/// A request the hub refused (4xx). On a job's endpoints, 409 means the
…
107 unchanged lines
124 124
        v["blob"].as_str().map(String::from).context("the hub named no blob")
125 125
    }
126 126
127 +
    /// Input `index` of job `job` (oxygenflow#58): the bytes of the
128 +
    /// producing job's artifact, which the caller checks against the blob the
129 +
    /// claim names.
130 +
    pub fn input(&self, job: i64, index: usize) -> Result<Vec<u8>> {
131 +
        let url = format!("{}/api/v1/flow/jobs/{job}/inputs/{index}", self.base);
132 +
        let r = retrying(&url, &PATIENT_DELAYS, false, || {
133 +
            self.agent
134 +
                .get(&url)
135 +
                .timeout(Duration::from_secs(600))
136 +
                .set("Authorization", &format!("Bearer {}", self.token))
137 +
                .call()
138 +
                .map_err(Box::new)
139 +
        })?;
140 +
        let mut bytes = Vec::new();
141 +
        let mut reader = r.into_reader();
142 +
        std::io::Read::read_to_end(&mut std::io::Read::take(&mut reader, MAX_INPUT_BYTES as u64 + 1), &mut bytes)?;
143 +
        if bytes.len() > MAX_INPUT_BYTES {
144 +
            bail!("larger than {} MiB", MAX_INPUT_BYTES >> 20);
145 +
        }
146 +
        Ok(bytes)
147 +
    }
148 +
127 149
    /// Upload file `path` of report `name` of job `job` (oxygenhub#127);
128 150
    /// the hub keeps it as a blob and answers with its ObjectRef. The
129 151
    /// report's manifest follows through [`Hub::artifact`].
…
130 unchanged lines
260 282
261 283
/// Waits for what a job leaves behind (oxygenflow#49): three minutes in
262 284
/// all, longer than a hub takes to restart.
285 +
/// Check an input's bytes against the blob the producing job's receipt
286 +
/// names, then write them at its path under `tree` (the workspace's `/src`).
287 +
fn place_input(tree: &Path, i: &flow_core::api::ArtifactInput, bytes: &[u8]) -> Result<[u8; 32]> {
288 +
    let Some(rel) = i.path.strip_prefix("/src/").filter(|r| r.split('/').all(|p| !p.is_empty() && p != "." && p != ".." && p != ".oxy"))
289 +
    else {
290 +
        bail!("path {:?} is not a clean path under /src", i.path);
291 +
    };
292 +
    let blob = Repo::blob_ref(bytes);
293 +
    if blob.to_hex() != i.blob {
294 +
        bail!("it is not what {}'s receipt names (blob {}, not {})", i.from, &blob.to_hex()[..12], &i.blob[..i.blob.len().min(12)]);
295 +
    }
296 +
    let dest = tree.join(rel);
297 +
    if let Some(dir) = dest.parent() {
298 +
        std::fs::create_dir_all(dir)?;
299 +
    }
300 +
    std::fs::write(&dest, bytes)?;
301 +
    Ok(blob.0)
302 +
}
303 +
304 +
/// The largest input a job takes: the hub's artifact limit.
305 +
pub const MAX_INPUT_BYTES: usize = 512 << 20;
306 +
263 307
const PATIENT_DELAYS: [Duration; 15] = {
264 308
    let mut d = [Duration::from_secs(15); 15];
265 309
    d[0] = Duration::from_secs(1);
…
536 unchanged lines
802 846
            files.extend(sub_files.into_iter().map(|(p, h)| (format!("{}/{p}", sub.path), h)));
803 847
        }
804 848
        let scope_hash = scope_tree_hash(&claim.job.scope, files.iter().map(|(p, h)| (p.as_str(), *h)));
849 +
        // Inputs (oxygenflow#58): each producing job's artifact, checked
850 +
        // against the blob its receipt names, then put at its path. Not part
851 +
        // of the scope: they are the run's, not the repository's.
852 +
        let mut input_blobs = Vec::new();
853 +
        for (index, i) in claim.inputs.iter().enumerate() {
854 +
            let what = format!("input {} from {}", i.artifact, i.from);
855 +
            let bytes = hub.input(claim.job_id, index).with_context(|| format!("{what}: fetching it"))?;
856 +
            let blob = place_input(&scratch.join("tree"), i, &bytes).with_context(|| what.clone())?;
857 +
            note(&mut sink, format!("{what}: {} bytes at {}, blob {} as its receipt names", bytes.len(), i.path, &i.blob[..12]));
858 +
            input_blobs.push(blob);
859 +
        }
805 860
        // The syscall tier runs on the tree itself; a microVM gets an image.
806 861
        let syscall_tier = claim.sandbox.runtime == flow_core::Runtime::Syscall;
807 862
        if !syscall_tier {
…
118 unchanged lines
926 981
            job_name: claim.job.name.clone(),
927 982
            pipeline_hash: hex32(&claim.pipeline_hash),
928 983
            scope_tree_hash: scope_hash,
929 -
            inputs: vec![],
984 +
            inputs: input_blobs.clone(),
930 985
            environment: outcome.environment.hash(),
931 986
            outputs,
932 987
            exit_code: code as u32,
…
171 unchanged lines
1104 1159
1105 1160
#[cfg(test)]
1106 1161
mod tests {
1162 +
1163 +
    #[test]
1164 +
    fn an_input_is_placed_only_when_it_is_what_the_receipt_names() {
1165 +
        let dir = tempfile::tempdir().unwrap();
1166 +
        let bytes = b"the reviewed plan";
1167 +
        let blob = Repo::blob_ref(bytes).to_hex();
1168 +
        let input = |path: &str, blob: &str| flow_core::api::ArtifactInput {
1169 +
            artifact: "tfplan".into(),
1170 +
            from: "plan".into(),
1171 +
            path: path.into(),
1172 +
            blob: blob.into(),
1173 +
        };
1174 +
        let got = super::place_input(dir.path(), &input("/src/plans/tfplan", &blob), bytes).unwrap();
1175 +
        assert_eq!(hex::encode(got), blob);
1176 +
        assert_eq!(std::fs::read(dir.path().join("plans/tfplan")).unwrap(), bytes);
1177 +
        let wrong = super::place_input(dir.path(), &input("/src/other", &"0".repeat(64)), bytes).unwrap_err();
1178 +
        assert!(format!("{wrong:#}").contains("not what plan's receipt names"), "{wrong:#}");
1179 +
        assert!(!dir.path().join("other").exists(), "nothing written when it does not match");
1180 +
        for bad in ["/etc/passwd", "/src/../x", "/src/.oxy/x", "/src/"] {
1181 +
            assert!(super::place_input(dir.path(), &input(bad, &blob), bytes).is_err(), "{bad}");
1182 +
        }
1183 +
    }
1184 +
1107 1185
    use super::*;
1108 1186
1109 1187
    #[test]
…
291 unchanged lines
modified docs/spec.md +27 −0
…
297 unchanged lines
298 298
}
299 299
```
300 300
301 +
### 5.3.1 Artifacts between jobs (oxygenflow#58)
302 +
303 +
A job takes an artifact of a job it depends on:
304 +
305 +
```kdl
306 +
job "apply" {
307 +
    depends-on "plan"
308 +
    input {
309 +
        artifact "tfplan" from "plan"
310 +
        path "/src/tfplan"
311 +
    }
312 +
}
313 +
```
314 +
315 +
The producer must be named in `depends-on` and declare that name as an
316 +
artifact (not a report); paths are clean absolute paths under `/src`, at
317 +
most 8 inputs, and they are part of the job's hash (`[artifact, from,
318 +
path]`, field 10). Since the producer has passed when the job is handed
319 +
out, the hub puts each input in the claim with the blob the producer's
320 +
receipt names (a passing job's receipt names exactly the artifacts it
321 +
uploaded). Before the job starts, the runner fetches each from
322 +
`GET /api/v1/flow/jobs/{job}/inputs/{index}`, checks its Oxygen blob
323 +
reference against that blob, refuses the job if they differ, and writes it
324 +
at its path in the workspace (not part of the scope hash: it belongs to the
325 +
run, not the repository). The job's receipt lists the blobs as its
326 +
`inputs`, so an apply job's receipt proves which plan it applied.
327 +
301 328
### 5.4 The Build Cache (oxygenflow#28)
302 329
303 330
An `ephemeral://disk` mount may name a `cache`, and the files that key it:
…
351 unchanged lines