test(albok): sshfs pathname-withdrawal test for the .browse/ view passes (unit 4.3 exposure gate)
Replica of a browse view on nh3-albok, Linux sshfs 3.7.3 client in an ephemeral container on nh3-dev, N=5 per condition. Fresh open of a never-opened child through a cached listing: ENOENT 5/5 with sshfs defaults and 5/5 with hour-long client caches. Null control (no rename) opens 5/5; held fd and failed-rename positive controls read 5/5. Replica removed afterwards. Also closes the post-clear checks in persistent-memory: fv-ml1 GPU 0/1 plateau, nh3-nas usage, and the mislabelled sfsrv-ana pre-pbs4 item.
This commit is contained in:
@@ -120,15 +120,12 @@ _As of 2026-10-09 ~1955 PT. The newest subsection is first; older subsections ca
|
||||
- **Nothing is mid-action.** No claims held (`scripts/ops-log claims`). eshpfi is pushed through this snapshot's commit. ⚠ The working tree AND index are shared with infra-hermes and subagents: commit with `git commit -- <paths>` (auto-memory `feedback_shared_git_index_commit_pathspecs`). `graphify-out/GRAPH_REPORT.md` stays modified and uncommitted on purpose (auto-regenerated).
|
||||
- **Approved, NOT built (do not start before Prime says so after this snapshot):** SVOS app-traffic ingress for U13. Details, release (`afab123`, v0.0.9, schema 12) and gotchas are in the 2026-10-09 Recent-decisions entry; svos-dev's edit doc is `docs/operations/u13-svos-edit.md`; highseat-dev owns the schedule.
|
||||
- **Owed by me (no input needed):**
|
||||
- albok `.browse/` exposure test before ANY share: pick a surface (sshfs `follow_symlinks` is the lead), rename `.browse/` on nh3-albok, and a fresh open through the client's cached listing must FAIL; also record the failed-rename case. Report on albok-dev's thread; the operator's user needs the restricted wings' groups on the share host. → `stacks/albok-service/README.md`
|
||||
- fv-ml1 GPU 0/1 plateau read (Beszel `stats.g.<n>.mu` × 1.024) ~10-10; every seat restarted at 0034 10-08.
|
||||
- Destroy `zroot/ROOT/debian@pre-patch-20261008` on fv-ml1 ~10-15 if nothing regressed.
|
||||
- sfsrv-ana `pre-pbs4` snapshot delete is unverified (infra-ops has no key there; ask infra-hermes).
|
||||
- Re-read nh3-nas usage after the 10-08/09 GCs (79% at 10-07 2350).
|
||||
- nh3-svos housekeeping: releases `6c6abbe` and `a737a06` are no longer referenced by the front door and can be deleted (`ac42ace` holds the same static as the current `b588510`).
|
||||
- **Watching: Worldtree memory gate.** 10-09 FAILED on filing/user (9, 11, 8 of 12; median 0.75 < 0.80), the first fail that is not #416. Bootstrap from 27 prior runs puts P(median ≤ 9) ≈ 0.06 per batch, and the misses are the habitual fixtures, so it is not called a regression yet. But per-run means drifted 10.6 → 10.0 → 9.7 → 9.3 since the 10-08 fv-ml1 patch (writer + judge = flash-next on GPU 2). If the 10-10 batch also fails filing, infra-hermes frames it as a possible regression after that patch and sends it to me; then investigate flash-next. ⚠ worldtree-dev has not read mail in 6+ days, so the reports dead-end there (Prime's call, below).
|
||||
- **Watching: jekyll on `sec-reasoning` (cyberprev, GPU 0)** since ~1435 10-06 (Prime moved it off gen-large). sec holds 337k KV tokens. jekyll's ~180k context plus a long sec prompt (up to 192k) can overflow it, as gen-large did (preemptions; decode down to 2–16 tok/s). jekyll's context grows ~1k/call toward the 262k ceiling. It runs on the shared all-agents-local key; a key of its own would make its traffic attributable.
|
||||
- **Waiting on Prime:**
|
||||
- albok `.browse/` share: the sshfs withdrawal test PASSED (Linux client). Before a share: which wings his nh3-albok user (`vh`) gets (`albok-read` = fleet + library, `albok-personal` = notes; `albok-feedback` stays default-deny), and, if the workstation is macOS, a re-run of `scripts/oneoff/albok-browse-withdrawal/` on it.
|
||||
- Who sees Worldtree gate failures while worldtree-dev is not reading mail (Prime, Miranda, or keep waiting).
|
||||
- Rotate the fv-ml1 BMC password? It was printed into a local session transcript on 10-08 (never left nh3-dev); infra-ops leans no.
|
||||
- albok's 200-object load check, 2.01× against a 2× bar (albok-dev: the ruling is the operator's).
|
||||
@@ -189,6 +186,7 @@ Every vLLM seat on a shared card (GPU 0, GPU 1) has byte-pinned KV (`--kv-cache-
|
||||
|
||||
## Recent decisions
|
||||
|
||||
- `[2026-10-09]` **Post-clear checks closed (2015–2025 PT):** albok `.browse/` sshfs pathname-withdrawal test PASSED (replica on nh3-albok, Linux sshfs 3.7.3 client in a container; fresh open ENOENT 5/5 default and 5/5 hour-long caches; null + positive controls ok; failed rename = path served; reported to albok-dev) → `scripts/oneoff/albok-browse-withdrawal/README.md`. fv-ml1 plateau (Beszel 120m, 42 h after the 0034 10-08 restart): GPU 0 flat at 5,606 MiB free (~3.9 GB at parakeet's cap), GPU 1 median 9,459 free (~3.3 GB at intern-decision's 32k peak), no creep; GPU 2 643 MiB free = flash-next util 0.96, single tenant, by design. nh3-nas `/volume1` 71% (79% on 10-07). The "sfsrv-ana pre-pbs4" item was a mislabel: the pre-pbs4 snapshots were pfi-pve VM 100 and nh3-pve VM 105, dropped by infra-hermes 10-05 and read gone 10-09.
|
||||
- `[2026-10-09]` **albok `library` building (fiction, nonfiction) live for unit 4.5; Nemi + albok-dev re-minted with library grants (token grants are immutable), old tokens revoked 1555 by albok-dev.** → `stacks/albok-service/README.md`
|
||||
- `[2026-10-09]` **Homepage: The High Seat's description no longer says "SVOS" (Prime: author it yourself); SVOS's now says "Internal network + mesh".**
|
||||
- `[2026-10-09]` **APPROVED by Prime (10-09), BUILD DEFERRED until after his snapshot/clear: SVOS app-traffic ingress for U13** (apps on nh3-dev reach the nh3-svos kernel). Shape agreed with svos-dev: (1) kernel `--app-listen 127.0.0.1:8410`, which never trusts loopback (svos-dev builds it in U9a); (2) a Caddy origin `svos-kernel.phasefinal.com` on 10.100.50.43, no Basic auth, bearer passed, app paths only, allowlisted to nh3-dev; (3) kernel→bridge over plain LAN HTTP; kernel→seat host (terminal) via nh3-dev's wildcard Caddy `svos-seathost.nh3.phasefinal.com` (TLS, because the leg carries secrets). nh3-dev is NOT on the mesh; the contracts now say "fleet network". `--app-listen` is ON MAIN (`68c9933`, 10-09). It ships in kernel **0.0.9** with schema **11 → 12** (migration 0012 rebuilds dispatches + summons, so the 'schema 11' acceptance line changes and a pre-refresh backup is mandatory). At the cutover the bridge gets three owner-approved runtime grants (app-postoffice → `app:app-miranda` write, `app:app-postoffice` write, `ns:pages` write, all autonomous) and installs as `svos-postoffice[push]` with `althing-core` from `~/development/althing` in apps-env. **Release is cut: svoperatingsystem `afab123` (tag v0.0.9)**: kernel 0.0.9, svos-postoffice 0.0.2, svos-terminal 0.0.3, svos-connector 0.0.7, svos-agent 0.0.3, svos-miranda 0.0.2 (U9a + U5b). The SVOS side of the cutover is written up in `docs/operations/u13-svos-edit.md` (app listener on nh3-svos, terminal app moved to nh3-dev with `[seat_host]`, bridge on nh3-dev with the converse mapping). Gotchas: the terminal app serves WITHOUT its seat host if the socket or launch dir fails (one stderr line); `serve` refuses `--app-listen` on the primary port. Build only when the edit is scheduled (highseat-dev owns the schedule) and NOT before the snapshot; the runbook refresh is ours at deploy. Tracking: svos-dev thread `01M4GPXSBKQHCQ30FVPH4R3N7G`.
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# albok browse view: sshfs pathname-withdrawal test (unit 4.3 exposure gate)
|
||||
|
||||
The albok unit 4.3 contract gates any workstation exposure of a wing's `.browse/` on infra-ops
|
||||
demonstrating, on the chosen share surface, that renaming `.browse/` away on the server denies a
|
||||
**fresh open of a never-opened child through a listing the client has cached**. The rename is what
|
||||
albok-service does when it withholds a view (`.browse/` → `.browse.withheld/`).
|
||||
|
||||
**Result (2026-10-09 2019–2021 PT): PASS on sshfs `follow_symlinks`, Linux client.** Raw output:
|
||||
`results-20261009-2019.json`; replica ids: `manifest.json`.
|
||||
|
||||
## Harness
|
||||
|
||||
- **Server:** a replica of a browse view on nh3-albok (`setup-replica.py`): 60 × 1 MiB objects at
|
||||
`<wing>/<id[:2]>/<id>/<blob>`, `.browse/stash/<name>` → `../../<id[:2]>/<id>/<blob>`, the same shape
|
||||
as the live view. Same root ext4 and the same OpenSSH `sftp-server` as `/srv/albok`. The live view
|
||||
was NOT used: renaming it races albok-service's link/unlink and a failed mandatory unlink withholds
|
||||
the wing. The replica was removed after the run (ops-log 2026-10-10T03:22:22Z).
|
||||
- **Client:** ephemeral `debian:bookworm-slim` container on nh3-dev (`run.sh`): SSHFS 3.7.3,
|
||||
libfuse 3.14.0, FUSE kernel interface 7.31, host kernel 6.1. Mount: `ro,follow_symlinks` plus SSH
|
||||
options; the mount root is `.browse/` alone.
|
||||
- **Per trial**, on a fresh mount: list `stash/` and stat every child (the client's caches now hold
|
||||
the listing and every child's attributes); open+close child A; open child B and **hold** the fd;
|
||||
rename on the server with `os.rename`; then immediately list again, stat C, open C (never opened
|
||||
before), read B at offset 1 MiB − 8 KiB (past readahead, so it must reach the server), re-open A.
|
||||
- **Conditions, N = 5 each**, distinct children every trial:
|
||||
`null` (no rename), `default` (sshfs defaults), `aggressive` (`kernel_cache,entry_timeout=3600,
|
||||
attr_timeout=3600,dir_cache=yes,dcache_timeout=3600`), `failed_rename` (target pre-created
|
||||
non-empty, so `rename(2)` refuses with ENOTEMPTY).
|
||||
|
||||
## Results
|
||||
|
||||
| condition | rename | cached list after | stat C after | **open C (fresh)** | held B far read | re-open A | list at +25 s |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| null (null control) | none | 60 | ok | **opens, bytes ok** 5/5 | ok 5/5 | opens 5/5 | n/a |
|
||||
| default | ok | 60 (stale) | ok (cached) | **ENOENT 5/5** | ok 5/5 | ENOENT 5/5 | ENOENT 5/5 |
|
||||
| aggressive caches | ok | 60 (stale) | ok (cached) | **ENOENT 5/5** | ok 5/5 | ENOENT 5/5 | n/a |
|
||||
| failed_rename | refused ENOTEMPTY | 60 | ok | **opens, bytes ok** 5/5 | ok 5/5 | opens 5/5 | n/a |
|
||||
|
||||
The fresh open was attempted 2–6 ms after the rename returned. Every trial started and ended with
|
||||
the server in the clean state (`.browse/` present, no `.browse.withheld`).
|
||||
|
||||
- **The gate holds.** The client still listed the child and stat'ed it from cache, and the open
|
||||
failed anyway: each sshfs open is an SFTP OPEN by path, resolved on the server, and hour-long
|
||||
client caches do not change that.
|
||||
- **Controls.** With no rename every open succeeds (the method does not manufacture failures). The
|
||||
held fd keeps reading after the rename, and a refused rename leaves every path served, so the
|
||||
instrument does register access that survives a rename (positive controls).
|
||||
- **What a rename does NOT withdraw:** a file already open on the client keeps reading through its
|
||||
SFTP handle. That is the contract's narrowed guarantee (new access by pathname only).
|
||||
- **Failed rename:** the path remains and is served. The service cannot deny opens it could not
|
||||
withdraw; its `BROWSE_WITHHELD` marker only stops publication.
|
||||
|
||||
## Limits
|
||||
|
||||
- **Proven for the Linux kernel FUSE client only.** A macOS workstation runs a different FUSE
|
||||
backend (macFUSE kext, macFUSE FSKit, or fuse-t, which is NFS-backed and may cache handles the way
|
||||
the contract warns NFS does). Re-run this harness on the real workstation before trusting that
|
||||
surface: rebuild the replica, then run `run-trials.py` directly with `ALBOK_TEST_KEY`,
|
||||
`ALBOK_TEST_KNOWN_HOSTS`, `ALBOK_TEST_MNT` (and `ALBOK_TEST_SRV`) set.
|
||||
- **The mount root is a presentation choice, not a boundary.** The SSH user's authority is whatever
|
||||
its groups can read: a member of the wing's read group can mount the wing root and see raw object
|
||||
directories, sidecars with their sensitivity records, and git history. The contract already says
|
||||
this ("the wing group's raw read is authority over files").
|
||||
- The committed `run-trials.py` adds env overrides and a umount fallback after the run; the container
|
||||
defaults it ran with are unchanged.
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,880 @@
|
||||
{
|
||||
"started_note": "times are monotonic seconds; see run.sh for wall clock",
|
||||
"sshfs_base": [
|
||||
"ro",
|
||||
"follow_symlinks",
|
||||
"IdentityFile=/keys/id",
|
||||
"UserKnownHostsFile=/keys/known_hosts",
|
||||
"StrictHostKeyChecking=yes",
|
||||
"BatchMode=yes"
|
||||
],
|
||||
"aggressive": [
|
||||
"kernel_cache",
|
||||
"entry_timeout=3600",
|
||||
"attr_timeout=3600",
|
||||
"dir_cache=yes",
|
||||
"dcache_timeout=3600"
|
||||
],
|
||||
"trials": [
|
||||
{
|
||||
"cond": "null",
|
||||
"trial": 0,
|
||||
"A": "doc00.md",
|
||||
"B": "doc01.md",
|
||||
"C": "doc02.md",
|
||||
"server_before": "True False",
|
||||
"pre_list": 60,
|
||||
"pre_A": {
|
||||
"opened": true,
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"pre_B_ok": true,
|
||||
"post_list": {
|
||||
"n": 60
|
||||
},
|
||||
"post_stat_C": {
|
||||
"ok": true,
|
||||
"size": 1048576
|
||||
},
|
||||
"post_open_C": {
|
||||
"opened": true,
|
||||
"read": 4096,
|
||||
"content_ok": true,
|
||||
"secs_after_rename": 0.002
|
||||
},
|
||||
"post_held_B": {
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"post_reopen_A": {
|
||||
"opened": true,
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"server_after": "True False"
|
||||
},
|
||||
{
|
||||
"cond": "null",
|
||||
"trial": 1,
|
||||
"A": "doc03.md",
|
||||
"B": "doc04.md",
|
||||
"C": "doc05.md",
|
||||
"server_before": "True False",
|
||||
"pre_list": 60,
|
||||
"pre_A": {
|
||||
"opened": true,
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"pre_B_ok": true,
|
||||
"post_list": {
|
||||
"n": 60
|
||||
},
|
||||
"post_stat_C": {
|
||||
"ok": true,
|
||||
"size": 1048576
|
||||
},
|
||||
"post_open_C": {
|
||||
"opened": true,
|
||||
"read": 4096,
|
||||
"content_ok": true,
|
||||
"secs_after_rename": 0.002
|
||||
},
|
||||
"post_held_B": {
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"post_reopen_A": {
|
||||
"opened": true,
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"server_after": "True False"
|
||||
},
|
||||
{
|
||||
"cond": "null",
|
||||
"trial": 2,
|
||||
"A": "doc06.md",
|
||||
"B": "doc07.md",
|
||||
"C": "doc08.md",
|
||||
"server_before": "True False",
|
||||
"pre_list": 60,
|
||||
"pre_A": {
|
||||
"opened": true,
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"pre_B_ok": true,
|
||||
"post_list": {
|
||||
"n": 60
|
||||
},
|
||||
"post_stat_C": {
|
||||
"ok": true,
|
||||
"size": 1048576
|
||||
},
|
||||
"post_open_C": {
|
||||
"opened": true,
|
||||
"read": 4096,
|
||||
"content_ok": true,
|
||||
"secs_after_rename": 0.002
|
||||
},
|
||||
"post_held_B": {
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"post_reopen_A": {
|
||||
"opened": true,
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"server_after": "True False"
|
||||
},
|
||||
{
|
||||
"cond": "null",
|
||||
"trial": 3,
|
||||
"A": "doc09.md",
|
||||
"B": "doc10.md",
|
||||
"C": "doc11.md",
|
||||
"server_before": "True False",
|
||||
"pre_list": 60,
|
||||
"pre_A": {
|
||||
"opened": true,
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"pre_B_ok": true,
|
||||
"post_list": {
|
||||
"n": 60
|
||||
},
|
||||
"post_stat_C": {
|
||||
"ok": true,
|
||||
"size": 1048576
|
||||
},
|
||||
"post_open_C": {
|
||||
"opened": true,
|
||||
"read": 4096,
|
||||
"content_ok": true,
|
||||
"secs_after_rename": 0.002
|
||||
},
|
||||
"post_held_B": {
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"post_reopen_A": {
|
||||
"opened": true,
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"server_after": "True False"
|
||||
},
|
||||
{
|
||||
"cond": "null",
|
||||
"trial": 4,
|
||||
"A": "doc12.md",
|
||||
"B": "doc13.md",
|
||||
"C": "doc14.md",
|
||||
"server_before": "True False",
|
||||
"pre_list": 60,
|
||||
"pre_A": {
|
||||
"opened": true,
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"pre_B_ok": true,
|
||||
"post_list": {
|
||||
"n": 60
|
||||
},
|
||||
"post_stat_C": {
|
||||
"ok": true,
|
||||
"size": 1048576
|
||||
},
|
||||
"post_open_C": {
|
||||
"opened": true,
|
||||
"read": 4096,
|
||||
"content_ok": true,
|
||||
"secs_after_rename": 0.002
|
||||
},
|
||||
"post_held_B": {
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"post_reopen_A": {
|
||||
"opened": true,
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"server_after": "True False"
|
||||
},
|
||||
{
|
||||
"cond": "default",
|
||||
"trial": 0,
|
||||
"A": "doc15.md",
|
||||
"B": "doc16.md",
|
||||
"C": "doc17.md",
|
||||
"server_before": "True False",
|
||||
"pre_list": 60,
|
||||
"pre_A": {
|
||||
"opened": true,
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"pre_B_ok": true,
|
||||
"rename": {
|
||||
"rc": 0,
|
||||
"out": "renamed",
|
||||
"err": "",
|
||||
"secs": 0.018
|
||||
},
|
||||
"post_list": {
|
||||
"n": 60
|
||||
},
|
||||
"post_stat_C": {
|
||||
"ok": true,
|
||||
"size": 1048576
|
||||
},
|
||||
"post_open_C": {
|
||||
"opened": false,
|
||||
"errno": "ENOENT",
|
||||
"secs_after_rename": 0.002
|
||||
},
|
||||
"post_held_B": {
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"post_reopen_A": {
|
||||
"opened": false,
|
||||
"errno": "ENOENT"
|
||||
},
|
||||
"late_list": {
|
||||
"errno": "ENOENT",
|
||||
"secs_after_rename": 25.003
|
||||
},
|
||||
"restore": "renamed",
|
||||
"server_after": "True False"
|
||||
},
|
||||
{
|
||||
"cond": "default",
|
||||
"trial": 1,
|
||||
"A": "doc18.md",
|
||||
"B": "doc19.md",
|
||||
"C": "doc20.md",
|
||||
"server_before": "True False",
|
||||
"pre_list": 60,
|
||||
"pre_A": {
|
||||
"opened": true,
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"pre_B_ok": true,
|
||||
"rename": {
|
||||
"rc": 0,
|
||||
"out": "renamed",
|
||||
"err": "",
|
||||
"secs": 0.017
|
||||
},
|
||||
"post_list": {
|
||||
"n": 60
|
||||
},
|
||||
"post_stat_C": {
|
||||
"ok": true,
|
||||
"size": 1048576
|
||||
},
|
||||
"post_open_C": {
|
||||
"opened": false,
|
||||
"errno": "ENOENT",
|
||||
"secs_after_rename": 0.004
|
||||
},
|
||||
"post_held_B": {
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"post_reopen_A": {
|
||||
"opened": false,
|
||||
"errno": "ENOENT"
|
||||
},
|
||||
"late_list": {
|
||||
"errno": "ENOENT",
|
||||
"secs_after_rename": 25.001
|
||||
},
|
||||
"restore": "renamed",
|
||||
"server_after": "True False"
|
||||
},
|
||||
{
|
||||
"cond": "default",
|
||||
"trial": 2,
|
||||
"A": "doc21.md",
|
||||
"B": "doc22.md",
|
||||
"C": "doc23.md",
|
||||
"server_before": "True False",
|
||||
"pre_list": 60,
|
||||
"pre_A": {
|
||||
"opened": true,
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"pre_B_ok": true,
|
||||
"rename": {
|
||||
"rc": 0,
|
||||
"out": "renamed",
|
||||
"err": "",
|
||||
"secs": 0.014
|
||||
},
|
||||
"post_list": {
|
||||
"n": 60
|
||||
},
|
||||
"post_stat_C": {
|
||||
"ok": true,
|
||||
"size": 1048576
|
||||
},
|
||||
"post_open_C": {
|
||||
"opened": false,
|
||||
"errno": "ENOENT",
|
||||
"secs_after_rename": 0.002
|
||||
},
|
||||
"post_held_B": {
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"post_reopen_A": {
|
||||
"opened": false,
|
||||
"errno": "ENOENT"
|
||||
},
|
||||
"late_list": {
|
||||
"errno": "ENOENT",
|
||||
"secs_after_rename": 25.003
|
||||
},
|
||||
"restore": "renamed",
|
||||
"server_after": "True False"
|
||||
},
|
||||
{
|
||||
"cond": "default",
|
||||
"trial": 3,
|
||||
"A": "doc24.md",
|
||||
"B": "doc25.md",
|
||||
"C": "doc26.md",
|
||||
"server_before": "True False",
|
||||
"pre_list": 60,
|
||||
"pre_A": {
|
||||
"opened": true,
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"pre_B_ok": true,
|
||||
"rename": {
|
||||
"rc": 0,
|
||||
"out": "renamed",
|
||||
"err": "",
|
||||
"secs": 0.019
|
||||
},
|
||||
"post_list": {
|
||||
"n": 60
|
||||
},
|
||||
"post_stat_C": {
|
||||
"ok": true,
|
||||
"size": 1048576
|
||||
},
|
||||
"post_open_C": {
|
||||
"opened": false,
|
||||
"errno": "ENOENT",
|
||||
"secs_after_rename": 0.002
|
||||
},
|
||||
"post_held_B": {
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"post_reopen_A": {
|
||||
"opened": false,
|
||||
"errno": "ENOENT"
|
||||
},
|
||||
"late_list": {
|
||||
"errno": "ENOENT",
|
||||
"secs_after_rename": 25.003
|
||||
},
|
||||
"restore": "renamed",
|
||||
"server_after": "True False"
|
||||
},
|
||||
{
|
||||
"cond": "default",
|
||||
"trial": 4,
|
||||
"A": "doc27.md",
|
||||
"B": "doc28.md",
|
||||
"C": "doc29.md",
|
||||
"server_before": "True False",
|
||||
"pre_list": 60,
|
||||
"pre_A": {
|
||||
"opened": true,
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"pre_B_ok": true,
|
||||
"rename": {
|
||||
"rc": 0,
|
||||
"out": "renamed",
|
||||
"err": "",
|
||||
"secs": 0.018
|
||||
},
|
||||
"post_list": {
|
||||
"n": 60
|
||||
},
|
||||
"post_stat_C": {
|
||||
"ok": true,
|
||||
"size": 1048576
|
||||
},
|
||||
"post_open_C": {
|
||||
"opened": false,
|
||||
"errno": "ENOENT",
|
||||
"secs_after_rename": 0.006
|
||||
},
|
||||
"post_held_B": {
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"post_reopen_A": {
|
||||
"opened": false,
|
||||
"errno": "ENOENT"
|
||||
},
|
||||
"late_list": {
|
||||
"errno": "ENOENT",
|
||||
"secs_after_rename": 25.002
|
||||
},
|
||||
"restore": "renamed",
|
||||
"server_after": "True False"
|
||||
},
|
||||
{
|
||||
"cond": "aggressive",
|
||||
"trial": 0,
|
||||
"A": "doc30.md",
|
||||
"B": "doc31.md",
|
||||
"C": "doc32.md",
|
||||
"server_before": "True False",
|
||||
"pre_list": 60,
|
||||
"pre_A": {
|
||||
"opened": true,
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"pre_B_ok": true,
|
||||
"rename": {
|
||||
"rc": 0,
|
||||
"out": "renamed",
|
||||
"err": "",
|
||||
"secs": 0.019
|
||||
},
|
||||
"post_list": {
|
||||
"n": 60
|
||||
},
|
||||
"post_stat_C": {
|
||||
"ok": true,
|
||||
"size": 1048576
|
||||
},
|
||||
"post_open_C": {
|
||||
"opened": false,
|
||||
"errno": "ENOENT",
|
||||
"secs_after_rename": 0.003
|
||||
},
|
||||
"post_held_B": {
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"post_reopen_A": {
|
||||
"opened": false,
|
||||
"errno": "ENOENT"
|
||||
},
|
||||
"restore": "renamed",
|
||||
"server_after": "True False"
|
||||
},
|
||||
{
|
||||
"cond": "aggressive",
|
||||
"trial": 1,
|
||||
"A": "doc33.md",
|
||||
"B": "doc34.md",
|
||||
"C": "doc35.md",
|
||||
"server_before": "True False",
|
||||
"pre_list": 60,
|
||||
"pre_A": {
|
||||
"opened": true,
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"pre_B_ok": true,
|
||||
"rename": {
|
||||
"rc": 0,
|
||||
"out": "renamed",
|
||||
"err": "",
|
||||
"secs": 0.016
|
||||
},
|
||||
"post_list": {
|
||||
"n": 60
|
||||
},
|
||||
"post_stat_C": {
|
||||
"ok": true,
|
||||
"size": 1048576
|
||||
},
|
||||
"post_open_C": {
|
||||
"opened": false,
|
||||
"errno": "ENOENT",
|
||||
"secs_after_rename": 0.002
|
||||
},
|
||||
"post_held_B": {
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"post_reopen_A": {
|
||||
"opened": false,
|
||||
"errno": "ENOENT"
|
||||
},
|
||||
"restore": "renamed",
|
||||
"server_after": "True False"
|
||||
},
|
||||
{
|
||||
"cond": "aggressive",
|
||||
"trial": 2,
|
||||
"A": "doc36.md",
|
||||
"B": "doc37.md",
|
||||
"C": "doc38.md",
|
||||
"server_before": "True False",
|
||||
"pre_list": 60,
|
||||
"pre_A": {
|
||||
"opened": true,
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"pre_B_ok": true,
|
||||
"rename": {
|
||||
"rc": 0,
|
||||
"out": "renamed",
|
||||
"err": "",
|
||||
"secs": 0.017
|
||||
},
|
||||
"post_list": {
|
||||
"n": 60
|
||||
},
|
||||
"post_stat_C": {
|
||||
"ok": true,
|
||||
"size": 1048576
|
||||
},
|
||||
"post_open_C": {
|
||||
"opened": false,
|
||||
"errno": "ENOENT",
|
||||
"secs_after_rename": 0.002
|
||||
},
|
||||
"post_held_B": {
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"post_reopen_A": {
|
||||
"opened": false,
|
||||
"errno": "ENOENT"
|
||||
},
|
||||
"restore": "renamed",
|
||||
"server_after": "True False"
|
||||
},
|
||||
{
|
||||
"cond": "aggressive",
|
||||
"trial": 3,
|
||||
"A": "doc39.md",
|
||||
"B": "doc40.md",
|
||||
"C": "doc41.md",
|
||||
"server_before": "True False",
|
||||
"pre_list": 60,
|
||||
"pre_A": {
|
||||
"opened": true,
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"pre_B_ok": true,
|
||||
"rename": {
|
||||
"rc": 0,
|
||||
"out": "renamed",
|
||||
"err": "",
|
||||
"secs": 0.015
|
||||
},
|
||||
"post_list": {
|
||||
"n": 60
|
||||
},
|
||||
"post_stat_C": {
|
||||
"ok": true,
|
||||
"size": 1048576
|
||||
},
|
||||
"post_open_C": {
|
||||
"opened": false,
|
||||
"errno": "ENOENT",
|
||||
"secs_after_rename": 0.002
|
||||
},
|
||||
"post_held_B": {
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"post_reopen_A": {
|
||||
"opened": false,
|
||||
"errno": "ENOENT"
|
||||
},
|
||||
"restore": "renamed",
|
||||
"server_after": "True False"
|
||||
},
|
||||
{
|
||||
"cond": "aggressive",
|
||||
"trial": 4,
|
||||
"A": "doc42.md",
|
||||
"B": "doc43.md",
|
||||
"C": "doc44.md",
|
||||
"server_before": "True False",
|
||||
"pre_list": 60,
|
||||
"pre_A": {
|
||||
"opened": true,
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"pre_B_ok": true,
|
||||
"rename": {
|
||||
"rc": 0,
|
||||
"out": "renamed",
|
||||
"err": "",
|
||||
"secs": 0.013
|
||||
},
|
||||
"post_list": {
|
||||
"n": 60
|
||||
},
|
||||
"post_stat_C": {
|
||||
"ok": true,
|
||||
"size": 1048576
|
||||
},
|
||||
"post_open_C": {
|
||||
"opened": false,
|
||||
"errno": "ENOENT",
|
||||
"secs_after_rename": 0.003
|
||||
},
|
||||
"post_held_B": {
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"post_reopen_A": {
|
||||
"opened": false,
|
||||
"errno": "ENOENT"
|
||||
},
|
||||
"restore": "renamed",
|
||||
"server_after": "True False"
|
||||
},
|
||||
{
|
||||
"cond": "failed_rename",
|
||||
"trial": 0,
|
||||
"A": "doc45.md",
|
||||
"B": "doc46.md",
|
||||
"C": "doc47.md",
|
||||
"server_before": "True False",
|
||||
"pre_list": 60,
|
||||
"pre_A": {
|
||||
"opened": true,
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"pre_B_ok": true,
|
||||
"rename": {
|
||||
"rc": 3,
|
||||
"out": "refused ENOTEMPTY",
|
||||
"err": "",
|
||||
"secs": 0.018
|
||||
},
|
||||
"post_list": {
|
||||
"n": 60
|
||||
},
|
||||
"post_stat_C": {
|
||||
"ok": true,
|
||||
"size": 1048576
|
||||
},
|
||||
"post_open_C": {
|
||||
"opened": true,
|
||||
"read": 4096,
|
||||
"content_ok": true,
|
||||
"secs_after_rename": 0.003
|
||||
},
|
||||
"post_held_B": {
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"post_reopen_A": {
|
||||
"opened": true,
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"server_after": "True False"
|
||||
},
|
||||
{
|
||||
"cond": "failed_rename",
|
||||
"trial": 1,
|
||||
"A": "doc48.md",
|
||||
"B": "doc49.md",
|
||||
"C": "doc50.md",
|
||||
"server_before": "True False",
|
||||
"pre_list": 60,
|
||||
"pre_A": {
|
||||
"opened": true,
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"pre_B_ok": true,
|
||||
"rename": {
|
||||
"rc": 3,
|
||||
"out": "refused ENOTEMPTY",
|
||||
"err": "",
|
||||
"secs": 0.016
|
||||
},
|
||||
"post_list": {
|
||||
"n": 60
|
||||
},
|
||||
"post_stat_C": {
|
||||
"ok": true,
|
||||
"size": 1048576
|
||||
},
|
||||
"post_open_C": {
|
||||
"opened": true,
|
||||
"read": 4096,
|
||||
"content_ok": true,
|
||||
"secs_after_rename": 0.003
|
||||
},
|
||||
"post_held_B": {
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"post_reopen_A": {
|
||||
"opened": true,
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"server_after": "True False"
|
||||
},
|
||||
{
|
||||
"cond": "failed_rename",
|
||||
"trial": 2,
|
||||
"A": "doc51.md",
|
||||
"B": "doc52.md",
|
||||
"C": "doc53.md",
|
||||
"server_before": "True False",
|
||||
"pre_list": 60,
|
||||
"pre_A": {
|
||||
"opened": true,
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"pre_B_ok": true,
|
||||
"rename": {
|
||||
"rc": 3,
|
||||
"out": "refused ENOTEMPTY",
|
||||
"err": "",
|
||||
"secs": 0.018
|
||||
},
|
||||
"post_list": {
|
||||
"n": 60
|
||||
},
|
||||
"post_stat_C": {
|
||||
"ok": true,
|
||||
"size": 1048576
|
||||
},
|
||||
"post_open_C": {
|
||||
"opened": true,
|
||||
"read": 4096,
|
||||
"content_ok": true,
|
||||
"secs_after_rename": 0.003
|
||||
},
|
||||
"post_held_B": {
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"post_reopen_A": {
|
||||
"opened": true,
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"server_after": "True False"
|
||||
},
|
||||
{
|
||||
"cond": "failed_rename",
|
||||
"trial": 3,
|
||||
"A": "doc54.md",
|
||||
"B": "doc55.md",
|
||||
"C": "doc56.md",
|
||||
"server_before": "True False",
|
||||
"pre_list": 60,
|
||||
"pre_A": {
|
||||
"opened": true,
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"pre_B_ok": true,
|
||||
"rename": {
|
||||
"rc": 3,
|
||||
"out": "refused ENOTEMPTY",
|
||||
"err": "",
|
||||
"secs": 0.017
|
||||
},
|
||||
"post_list": {
|
||||
"n": 60
|
||||
},
|
||||
"post_stat_C": {
|
||||
"ok": true,
|
||||
"size": 1048576
|
||||
},
|
||||
"post_open_C": {
|
||||
"opened": true,
|
||||
"read": 4096,
|
||||
"content_ok": true,
|
||||
"secs_after_rename": 0.002
|
||||
},
|
||||
"post_held_B": {
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"post_reopen_A": {
|
||||
"opened": true,
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"server_after": "True False"
|
||||
},
|
||||
{
|
||||
"cond": "failed_rename",
|
||||
"trial": 4,
|
||||
"A": "doc57.md",
|
||||
"B": "doc58.md",
|
||||
"C": "doc59.md",
|
||||
"server_before": "True False",
|
||||
"pre_list": 60,
|
||||
"pre_A": {
|
||||
"opened": true,
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"pre_B_ok": true,
|
||||
"rename": {
|
||||
"rc": 3,
|
||||
"out": "refused ENOTEMPTY",
|
||||
"err": "",
|
||||
"secs": 0.018
|
||||
},
|
||||
"post_list": {
|
||||
"n": 60
|
||||
},
|
||||
"post_stat_C": {
|
||||
"ok": true,
|
||||
"size": 1048576
|
||||
},
|
||||
"post_open_C": {
|
||||
"opened": true,
|
||||
"read": 4096,
|
||||
"content_ok": true,
|
||||
"secs_after_rename": 0.003
|
||||
},
|
||||
"post_held_B": {
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"post_reopen_A": {
|
||||
"opened": true,
|
||||
"read": 4096,
|
||||
"content_ok": true
|
||||
},
|
||||
"server_after": "True False"
|
||||
}
|
||||
]
|
||||
}
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Pathname-withdrawal test for an albok browse view shared over sshfs.
|
||||
|
||||
Runs on the CLIENT (an ephemeral container, see run.sh) against the replica built
|
||||
by setup-replica.py. The gate (albok unit 4.3, exposure ruling): rename `.browse/`
|
||||
away on the server, then a FRESH open of a child that was never opened, through a
|
||||
listing the client has cached, must FAIL.
|
||||
|
||||
Per trial (fresh mount each time), three distinct children:
|
||||
A opened and closed before the rename -> re-opened after it
|
||||
B opened before the rename, fd HELD -> read at a far offset after it
|
||||
C listed and stat'ed, never opened -> opened after it (the gate)
|
||||
|
||||
Conditions (N trials each):
|
||||
null no rename C must OPEN (null control)
|
||||
default rename ok, sshfs defaults C must FAIL (the gate)
|
||||
aggressive rename ok, hour-long caches C must FAIL (gate, worst-case client caching)
|
||||
failed_rename rename refused (ENOTEMPTY) C OPENS: the path remains and is served
|
||||
B is the positive control: a retained handle must still read after a rename, which
|
||||
proves the instrument can see access that survives a rename.
|
||||
|
||||
Usage: run-trials.py <manifest.json> <trials-per-condition> (JSON to stdout)
|
||||
"""
|
||||
import errno
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
# Overridable so the same harness runs on a workstation (e.g. macOS) without the container.
|
||||
SRV = os.environ.get("ALBOK_TEST_SRV", "infra-ops@10.100.50.44")
|
||||
KEY = os.environ.get("ALBOK_TEST_KEY", "/keys/id")
|
||||
KH = os.environ.get("ALBOK_TEST_KNOWN_HOSTS", "/keys/known_hosts")
|
||||
MNT = os.environ.get("ALBOK_TEST_MNT", "/mnt/browse")
|
||||
SSH_OPTS = ["-i", KEY, "-o", "BatchMode=yes", "-o", f"UserKnownHostsFile={KH}",
|
||||
"-o", "StrictHostKeyChecking=yes", "-o", "ControlMaster=auto",
|
||||
"-o", "ControlPath=/tmp/cm-%r@%h", "-o", "ControlPersist=900"]
|
||||
SSHFS_BASE = ["ro", "follow_symlinks", f"IdentityFile={KEY}", f"UserKnownHostsFile={KH}",
|
||||
"StrictHostKeyChecking=yes", "BatchMode=yes"]
|
||||
AGGRESSIVE = ["kernel_cache", "entry_timeout=3600", "attr_timeout=3600",
|
||||
"dir_cache=yes", "dcache_timeout=3600"]
|
||||
CONDITIONS = [("null", [], None), ("default", [], "ok"),
|
||||
("aggressive", AGGRESSIVE, "ok"), ("failed_rename", [], "fail")]
|
||||
LATE_LIST_DELAY = 25 # past sshfs's default 20 s cache timeout
|
||||
|
||||
manifest = json.load(open(sys.argv[1]))
|
||||
N = int(sys.argv[2])
|
||||
ROOT = manifest["root"]
|
||||
SIZE = manifest["size"]
|
||||
OBJS = manifest["objects"]
|
||||
VIEW, WITHHELD = f"{ROOT}/.browse", f"{ROOT}/.browse.withheld"
|
||||
|
||||
|
||||
def ename(e: OSError) -> str:
|
||||
return errno.errorcode.get(e.errno or 0, str(e.errno))
|
||||
|
||||
|
||||
def content(o) -> bytes:
|
||||
line = f"replica object {o['i']} {o['id']}\n".encode()
|
||||
return (line * (SIZE // len(line) + 1))[:SIZE]
|
||||
|
||||
|
||||
def remote_py(code: str, *args: str):
|
||||
cmd = "python3 -c " + shlex.quote(code) + "".join(" " + shlex.quote(a) for a in args)
|
||||
t0 = time.monotonic()
|
||||
r = subprocess.run(["ssh", *SSH_OPTS, SRV, cmd], capture_output=True, text=True, timeout=60)
|
||||
return {"rc": r.returncode, "out": r.stdout.strip(), "err": r.stderr.strip()[-300:],
|
||||
"secs": round(time.monotonic() - t0, 3)}
|
||||
|
||||
|
||||
RENAME = ("import os,sys,errno\n"
|
||||
"try:\n os.rename(sys.argv[1], sys.argv[2]); print('renamed')\n"
|
||||
"except OSError as e:\n print('refused', errno.errorcode.get(e.errno)); sys.exit(3)")
|
||||
STATE = "import os,sys; print(os.path.isdir(sys.argv[1]), os.path.lexists(sys.argv[2]))"
|
||||
|
||||
|
||||
def open_read(path, expect, offset=0, n=4096):
|
||||
try:
|
||||
fd = os.open(path, os.O_RDONLY)
|
||||
except OSError as e:
|
||||
return {"opened": False, "errno": ename(e)}
|
||||
try:
|
||||
os.lseek(fd, offset, os.SEEK_SET)
|
||||
b = os.read(fd, n)
|
||||
return {"opened": True, "read": len(b), "content_ok": b == expect[offset:offset + n]}
|
||||
except OSError as e:
|
||||
return {"opened": True, "read_errno": ename(e)}
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def listing(d):
|
||||
try:
|
||||
return {"n": len(os.listdir(d))}
|
||||
except OSError as e:
|
||||
return {"errno": ename(e)}
|
||||
|
||||
|
||||
def stat(p):
|
||||
try:
|
||||
st = os.stat(p)
|
||||
return {"ok": True, "size": st.st_size}
|
||||
except OSError as e:
|
||||
return {"ok": False, "errno": ename(e)}
|
||||
|
||||
|
||||
def trial(cond, extra, rename, k, slot):
|
||||
A, B, C = OBJS[3 * slot], OBJS[3 * slot + 1], OBJS[3 * slot + 2]
|
||||
rec = {"cond": cond, "trial": k, "A": A["name"], "B": B["name"], "C": C["name"]}
|
||||
rec["server_before"] = remote_py(STATE, VIEW, WITHHELD)["out"]
|
||||
if rec["server_before"] != "True False":
|
||||
rec["aborted"] = "server not in the clean state"
|
||||
return rec
|
||||
if rename == "fail": # a non-empty target makes rename(2) refuse
|
||||
remote_py("import os,sys; os.makedirs(sys.argv[1])", f"{WITHHELD}/blocker")
|
||||
os.makedirs(MNT, exist_ok=True)
|
||||
m = subprocess.run(["sshfs", f"{SRV}:{VIEW}", MNT, "-o", ",".join(SSHFS_BASE + extra)],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
if m.returncode != 0:
|
||||
rec["aborted"] = f"mount failed: {m.stderr.strip()}"
|
||||
return rec
|
||||
renamed = False
|
||||
fd_b = None
|
||||
try:
|
||||
st_dir = f"{MNT}/stash"
|
||||
names = os.listdir(st_dir)
|
||||
rec["pre_list"] = len(names)
|
||||
for nm in names: # cache every child's attrs: the client "knows" C exists
|
||||
os.stat(f"{st_dir}/{nm}")
|
||||
rec["pre_A"] = open_read(f"{st_dir}/{A['name']}", content(A))
|
||||
fd_b = os.open(f"{st_dir}/{B['name']}", os.O_RDONLY)
|
||||
rec["pre_B_ok"] = os.read(fd_b, 4096) == content(B)[:4096]
|
||||
|
||||
if rename:
|
||||
rec["rename"] = remote_py(RENAME, VIEW, WITHHELD)
|
||||
renamed = rec["rename"]["out"] == "renamed"
|
||||
t0 = time.monotonic()
|
||||
|
||||
rec["post_list"] = listing(st_dir)
|
||||
rec["post_stat_C"] = stat(f"{st_dir}/{C['name']}")
|
||||
rec["post_open_C"] = open_read(f"{st_dir}/{C['name']}", content(C))
|
||||
rec["post_open_C"]["secs_after_rename"] = round(time.monotonic() - t0, 3)
|
||||
far = SIZE - 8192 # well past readahead: this read must go to the server
|
||||
try:
|
||||
os.lseek(fd_b, far, os.SEEK_SET)
|
||||
b = os.read(fd_b, 4096)
|
||||
rec["post_held_B"] = {"read": len(b), "content_ok": b == content(B)[far:far + 4096]}
|
||||
except OSError as e:
|
||||
rec["post_held_B"] = {"read_errno": ename(e)}
|
||||
rec["post_reopen_A"] = open_read(f"{st_dir}/{A['name']}", content(A))
|
||||
if cond == "default":
|
||||
time.sleep(max(0.0, LATE_LIST_DELAY - (time.monotonic() - t0)))
|
||||
rec["late_list"] = listing(st_dir)
|
||||
rec["late_list"]["secs_after_rename"] = round(time.monotonic() - t0, 3)
|
||||
finally:
|
||||
if fd_b is not None:
|
||||
os.close(fd_b)
|
||||
for um in (["fusermount3", "-u", MNT], ["fusermount", "-u", MNT], ["umount", MNT]):
|
||||
try:
|
||||
if subprocess.run(um, capture_output=True).returncode == 0:
|
||||
break
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
if renamed:
|
||||
rec["restore"] = remote_py(RENAME, WITHHELD, VIEW)["out"]
|
||||
if rename == "fail":
|
||||
remote_py("import os,sys; os.rmdir(sys.argv[1]); os.rmdir(sys.argv[2])",
|
||||
f"{WITHHELD}/blocker", WITHHELD)
|
||||
rec["server_after"] = remote_py(STATE, VIEW, WITHHELD)["out"]
|
||||
return rec
|
||||
|
||||
|
||||
results = []
|
||||
slot = 0
|
||||
for cond, extra, rename in CONDITIONS:
|
||||
for k in range(N):
|
||||
results.append(trial(cond, extra, rename, k, slot))
|
||||
slot += 1
|
||||
json.dump({"started_note": "times are monotonic seconds; see run.sh for wall clock",
|
||||
"sshfs_base": SSHFS_BASE, "aggressive": AGGRESSIVE, "trials": results},
|
||||
sys.stdout, indent=1)
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pathname-withdrawal test for the albok browse view over sshfs (unit 4.3 exposure gate).
|
||||
#
|
||||
# 1. ssh infra-ops@10.100.50.44 python3 - < setup-replica.py > manifest.json
|
||||
# 2. ./run.sh [trials-per-condition] # default 5; JSON lands in results-<stamp>.json
|
||||
# 3. remove the replica on nh3-albok by its literal path when done
|
||||
#
|
||||
# The client is an ephemeral Debian 12 container on nh3-dev (sshfs 3.7.3 / libfuse 3), so
|
||||
# nothing is installed on the host. It needs /dev/fuse and CAP_SYS_ADMIN to mount.
|
||||
set -euo pipefail
|
||||
here=$(cd "$(dirname "$0")" && pwd)
|
||||
n=${1:-5}
|
||||
stamp=$(date +%Y%m%d-%H%M)
|
||||
out="$here/results-$stamp.json"
|
||||
echo "start $(date '+%F %H%M %Z')" >&2
|
||||
docker run --rm --device /dev/fuse --cap-add SYS_ADMIN --security-opt apparmor=unconfined \
|
||||
-v "$HOME/.ssh/infra-ops_ed25519:/keys/id:ro" \
|
||||
-v "$HOME/.ssh/known_hosts:/keys/known_hosts:ro" \
|
||||
-v "$here:/work" \
|
||||
debian:bookworm-slim bash -c "
|
||||
apt-get -qq update >/dev/null && apt-get -qq install -y sshfs python3 openssh-client >/dev/null 2>&1
|
||||
sshfs --version >&2
|
||||
python3 /work/run-trials.py /work/manifest.json $n" > "$out"
|
||||
echo "end $(date '+%F %H%M %Z') -> $out" >&2
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a replica of an albok browse view on the share host (nh3-albok).
|
||||
|
||||
Run ON the server as infra-ops, no sudo:
|
||||
ssh infra-ops@10.100.50.44 python3 - < setup-replica.py > manifest.json
|
||||
|
||||
Why a replica and not the live view: the test renames `.browse/` away. On a live
|
||||
wing that rename races albok-service's own link()/unlink() calls, and a failed
|
||||
mandatory unlink withholds the wing (BROWSE_WITHHELD marker + a failed write
|
||||
transaction). The replica has the same shape as the real view
|
||||
(`<wing>/.browse/<locator>` -> relative symlink `../../<id[:2]>/<id>/<blob>`), sits
|
||||
on the same filesystem (root ext4), and is served by the same sftp-server, which
|
||||
is what the withdrawal property depends on.
|
||||
|
||||
Content is deterministic per object, so the client can verify bytes without
|
||||
fetching a reference copy.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
|
||||
ROOT = "/home/infra-ops/albok-browse-test/wing"
|
||||
COUNT = 60
|
||||
SIZE = 1 << 20 # 1 MiB: larger than FUSE readahead, so a late read must reach the server
|
||||
|
||||
|
||||
def content(i: int, oid: str) -> bytes:
|
||||
line = f"replica object {i} {oid}\n".encode()
|
||||
return (line * (SIZE // len(line) + 1))[:SIZE]
|
||||
|
||||
|
||||
os.makedirs(ROOT, exist_ok=False) # refuse to run over an existing replica
|
||||
link_dir = os.path.join(ROOT, ".browse", "stash")
|
||||
os.makedirs(link_dir)
|
||||
objs = []
|
||||
for i in range(COUNT):
|
||||
oid = uuid.uuid4().hex
|
||||
obj_dir = os.path.join(ROOT, oid[:2], oid)
|
||||
os.makedirs(obj_dir)
|
||||
name = f"doc{i:02d}.md"
|
||||
blob = os.path.join(obj_dir, name)
|
||||
data = content(i, oid)
|
||||
with open(blob, "wb") as f:
|
||||
f.write(data)
|
||||
os.chmod(blob, 0o640)
|
||||
os.symlink(os.path.relpath(blob, link_dir), os.path.join(link_dir, name))
|
||||
objs.append({"i": i, "id": oid, "name": name, "size": SIZE,
|
||||
"sha256": hashlib.sha256(data).hexdigest()})
|
||||
|
||||
print(json.dumps({"root": ROOT, "size": SIZE, "objects": objs}))
|
||||
@@ -22,8 +22,13 @@ The FastAPI process that owns Albok's buildings-and-wings store (`vh/albok`,
|
||||
`/health` gains `browse_withheld`; 0 is normal, and a non-zero value is a WARN. No config change.
|
||||
⚠ **`.browse/` is NOT shared with anyone yet.** The contract gates exposure on a demonstration on the chosen share:
|
||||
rename `.browse/` away on the server, then try a fresh open of an unopened child from the client through a cached
|
||||
listing. That open must fail. sshfs `follow_symlinks` works by path; NFS/SMB may keep directory handles. Report a
|
||||
passing surface to albok-dev, who tells the operator. Rollback: `compose.yaml.pre-0.1.14` (0.1.13) is free, because no
|
||||
listing. That open must fail. sshfs `follow_symlinks` works by path; NFS/SMB may keep directory handles.
|
||||
**[2026-10-09] PASSED on sshfs `ro,follow_symlinks` with a Linux client** (SSHFS 3.7.3 / libfuse 3.14): fresh open
|
||||
ENOENT 5/5 with sshfs defaults and 5/5 with hour-long client caches, while the cached listing and stat still showed
|
||||
the child; null and positive controls behaved (`scripts/oneoff/albok-browse-withdrawal/`). Reported to albok-dev.
|
||||
Still before any share: the workstation's own client must re-run that harness if it is macOS (different FUSE
|
||||
backend), and no user is in `albok-read`/`albok-personal` yet, so the operator's user needs those groups (the
|
||||
operator's call; `albok-feedback` stays default-deny). Rollback: `compose.yaml.pre-0.1.14` (0.1.13) is free, because no
|
||||
sidecar shape changed.
|
||||
Prior: 0.1.13 @ `sha256:bea95617…` (2026-10-07 1135 – 1804) (healthy 1136:17,
|
||||
between walks). infra-ops deployed it at albok-dev's ask (althing 8413), from a clean archive of vh/albok `70b55ad7`
|
||||
|
||||
Reference in New Issue
Block a user