From 5328571cc89e6ddef66aa7a8ff78d5e9da0a764a Mon Sep 17 00:00:00 2001 From: sehkone Date: Fri, 28 Aug 2026 15:11:43 +0900 Subject: [PATCH] Clarify retained audit-store exposure Document the retained failed-mount exposure so operators know that an existing underlying bind source remains writable until records move to the reserve. Closes #952 --- docs/en/operations.md | 20 +++-- docs/ko/operations.md | 22 +++--- src/commands/audit_store.rs | 15 ++-- src/commands/audit_store/reserve.rs | 117 +++++++++++++++++++++++++++- src/i18n/en.rs | 2 +- src/i18n/ko.rs | 2 +- 6 files changed, 153 insertions(+), 25 deletions(-) diff --git a/docs/en/operations.md b/docs/en/operations.md index c4468075..5693cb04 100644 --- a/docs/en/operations.md +++ b/docs/en/operations.md @@ -1078,14 +1078,18 @@ hidden may be the only copy of records written before the reserve existed. A store that is not empty is reported as **provisioned, not activated**. -Nothing is deleted, moved or mounted over; the three artifacts are still -written, because the operator will need them; and **no phase-2 command -whatever** is rendered, because a partial list is an invitation to keep -going and the step it ends at mounts a filesystem over those records. -There are two supported ways forward: relocate those records, a separate -procedure this build does not provide, or configure -`audit_store_enforcement = "directory"` and run without a kernel-enforced -ceiling. +The same store is refused by `bootroot infra up` on a live deployment +before the stack starts, so no container starts. Nothing is deleted, moved +or mounted over; the three artifacts are still written, because the +operator will need them; and **no phase-2 command whatever** is rendered, +because a partial list is an invitation to keep going and the step it ends +at mounts a filesystem over those records. `create_host_path: false` +governs creation only, so a failed-mount boot still binds an already +present underlying `openbao/` and writes the audit device on the root +filesystem until the records are relocated onto the reserve. There are two +supported ways forward: relocate those records, a separate procedure this +build does not provide, or configure `audit_store_enforcement = +"directory"` and run without a kernel-enforced ceiling. The same refusal reaches `bootroot reinit` **before it wipes anything**. Reinit's pre-wipe preflight raises this surface's phase-1 refusals — a diff --git a/docs/ko/operations.md b/docs/ko/operations.md index dbf9bb29..9a3c1140 100644 --- a/docs/ko/operations.md +++ b/docs/ko/operations.md @@ -1017,15 +1017,19 @@ systemd가 소유한 소켓에서 호출자를 계속 받고 peer 검사를 마 검증을 포함한 모든 판독기로부터 모든 바이트가 가려지며, 가려진 것이 예약량이 생기기 전에 기록된 레코드의 유일한 사본일 수 있습니다. -비어 있지 않은 저장소는 **provisioned, not activated**로 보고됩니다. -아무것도 삭제·이동되지 않고 그 위로 마운트되지도 않습니다. 세 산출물은 -여전히 기록됩니다. 운영자가 그것을 필요로 하기 때문입니다. 그리고 -**어떤 2단계 명령도** 렌더링하지 않습니다. 일부만 나열하는 것은 계속하라는 -초대이고, 그 목록이 끝나는 단계가 바로 그 레코드 위로 파일 시스템을 -마운트하는 단계이기 때문입니다. 지원되는 길은 둘입니다. 그 레코드를 -옮기거나(이 빌드가 제공하지 않는 별도 절차입니다), -`audit_store_enforcement = "directory"`로 설정하고 커널 강제 상한 없이 -운용하는 것입니다. +비어 있지 않은 저장소는 **provisioned, not activated**로 보고됩니다. 같은 +저장소는 실제 배포에서 `bootroot infra up`으로도 스택을 시작하기 전에 +거부되므로 어떤 컨테이너도 시작되지 않습니다. 아무것도 삭제·이동되지 않고 +그 위로 마운트되지도 않습니다. 세 산출물은 여전히 기록됩니다. 운영자가 +그것을 필요로 하기 때문입니다. 그리고 **어떤 2단계 명령도** 렌더링하지 +않습니다. 일부만 나열하는 것은 계속하라는 초대이고, 그 목록이 끝나는 +단계가 바로 그 레코드 위로 파일 시스템을 마운트하는 단계이기 때문입니다. +`create_host_path: false`는 생성만 통제하므로, 마운트가 실패한 부팅에서는 +하부에 이미 있는 `openbao/`가 여전히 바인드되어 audit device가 루트 파일 +시스템에 기록됩니다. 레코드를 예약량으로 옮길 때까지 이 노출은 남습니다. +지원되는 길은 둘입니다. 그 레코드를 옮기거나(이 빌드가 제공하지 않는 별도 +절차입니다), `audit_store_enforcement = "directory"`로 설정하고 커널 강제 +상한 없이 운용하는 것입니다. 같은 거부가 `bootroot reinit`에도 **무엇을 지우기 전에** 도달합니다. reinit의 사전 점검은 이 표면의 1단계 거부를 모두 순수 읽기로 제기합니다. diff --git a/src/commands/audit_store.rs b/src/commands/audit_store.rs index f0a84171..106fbef1 100644 --- a/src/commands/audit_store.rs +++ b/src/commands/audit_store.rs @@ -3050,11 +3050,12 @@ mod tests { assert!(!fixture.image_path().exists()); } - /// The re-run step names the command the run continues under, and - /// nothing on this surface tells an operator to reach for - /// `bootroot infra up`. + /// The re-run step names the command the run continues under. The + /// non-empty-store finding is the one deliberate exception: it + /// names the live `infra up` refusal without making it a re-run + /// instruction. #[test] - fn the_rerun_step_is_parameterised_and_never_names_infra_up() { + fn the_rerun_step_is_parameterised_while_the_non_empty_finding_names_infra_up() { for locale in ["en", "ko"] { let messages = Messages::new(locale).expect("a locale"); let rendered = messages.audit_reserve_step_rerun(RESERVE_RERUN_COMMAND); @@ -3067,12 +3068,16 @@ mod tests { messages.audit_reserve_outcome_not_activated("/store"), messages.audit_reserve_outcome_directory("/store", 1), messages.audit_reserve_outcome_enforced("/img", 1, "u.mount", "/store"), - messages.audit_reserve_finding_store_not_empty("/store"), messages.audit_reserve_steps_header().to_string(), messages.audit_reserve_openbao_owner_caveat().to_string(), ] { assert!(!text.contains("infra up"), "{locale}: {text}"); } + let finding = messages.audit_reserve_finding_store_not_empty("/store"); + assert!( + finding.contains("`bootroot infra up`"), + "{locale}: {finding}" + ); } } } diff --git a/src/commands/audit_store/reserve.rs b/src/commands/audit_store/reserve.rs index 67d94801..3eeb5696 100644 --- a/src/commands/audit_store/reserve.rs +++ b/src/commands/audit_store/reserve.rs @@ -2113,7 +2113,7 @@ mod tests { use tempfile::tempdir; use super::*; - use crate::i18n::test_messages; + use crate::i18n::{Messages, test_messages}; const DEFAULT_RESERVE: u64 = 2 * 1024 * 1024 * 1024; const DEFAULT_MAX_FILE_BYTES: u64 = 8 * 1024 * 1024; @@ -3690,6 +3690,121 @@ mod tests { } } + #[test] + fn a_non_empty_underlying_store_names_the_retained_bind_exposure_in_both_locales() { + let fixture = Fixture::default(); + let inputs = fixture.inputs(); + let host = bare_host() + .entries( + STORE, + &[&format!("{STORE}/records"), &format!("{STORE}/openbao")], + ) + .file(&format!("{STORE}/records"), dir_facts(TEST_UID, 0o700)) + .file(&format!("{STORE}/openbao"), dir_facts(TEST_UID, 0o700)); + + let locales: [(&str, &[&str]); 2] = [ + ( + "en", + &[ + "bring-up is refused before the stack starts, so no container starts", + "`create_host_path: false` governs creation only", + "/var/lib/bootroot/audit-store/openbao already exists", + "audit device writes on the root filesystem", + "until the records are relocated onto the reserve", + "Nothing was deleted, moved or mounted over", + "a separate procedure this build does not provide", + "`audit_store_enforcement = \"directory\"`", + ], + ), + ( + "ko", + &[ + "스택을 시작하기 전에 기동이 거부되어 어떤 컨테이너도 시작되지 않습니다", + "`create_host_path: false`는 생성만 통제합니다", + "/var/lib/bootroot/audit-store/openbao가 이미 있고", + "audit device는 루트 파일 시스템에 기록합니다", + "레코드를 예약량으로 옮길 때까지 남습니다", + "아무것도 삭제·이동되지 않았고 그 위로 마운트되지도 않았습니다", + "이 빌드가 제공하지 않는 별도 절차입니다", + "`audit_store_enforcement = \"directory\"`", + ], + ), + ]; + + for (locale, required_passages) in locales { + let messages = Messages::new(locale).expect("supported test locale"); + let facts = evaluate(&inputs, &host, &messages).expect("phase 1"); + let artifacts = render_artifacts(&inputs, &facts); + let report = verify(&inputs, &facts, &artifacts, &host, &messages).expect("phase 3"); + let text = render_filesystem_outcome(&inputs, &facts, &artifacts, &report, &messages); + + for passage in required_passages { + assert!(text.contains(passage), "{locale}: {text}"); + } + } + } + + #[test] + fn manual_existing_records_passages_name_the_live_refusal_without_a_procedure() { + let manuals: [(&str, &str, &[&str]); 2] = [ + ( + "docs/en/operations.md", + "#### A store that already holds records", + &[ + "The same store is refused by `bootroot infra up`", + "before the stack starts, so no container starts.", + "`create_host_path: false` governs creation only", + "until the records are relocated onto the reserve.", + "Nothing is deleted, moved or mounted over", + "a separate procedure this build does not provide", + ], + ), + ( + "docs/ko/operations.md", + "#### 이미 레코드를 담고 있는 저장소", + &[ + "저장소는 실제 배포에서 `bootroot infra up`으로도 스택을 시작하기 전에 거부되므로", + "거부되므로 어떤 컨테이너도 시작되지 않습니다.", + "`create_host_path: false`는 생성만 통제하므로", + "레코드를 예약량으로 옮길 때까지 이 노출은 남습니다.", + "아무것도 삭제·이동되지 않고 그 위로 마운트되지도 않습니다.", + "이 빌드가 제공하지 않는 별도 절차입니다", + ], + ), + ]; + + for (manual, heading, required_passages) in manuals { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(manual); + let source = fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("reading {}: {error}", path.display())); + let (_, after_heading) = source + .split_once(heading) + .unwrap_or_else(|| panic!("{manual}: existing-records heading is absent")); + let passage = after_heading + .split("\n#### ") + .next() + .expect("heading always has a following passage"); + let normalized = passage.split_whitespace().collect::>().join(" "); + + for required in required_passages { + assert!( + normalized.contains(required), + "{manual}: missing {required:?}" + ); + } + assert!( + !passage.contains("\n```"), + "{manual}: the existing-records passage must not add a relocation procedure" + ); + for command in ["`mv ", "`cp ", "`rsync ", "`rm ", "`mount ", "`umount "] { + assert!( + !passage.contains(command), + "{manual}: the existing-records passage must not add a relocation command: {command}" + ); + } + } + } + #[test] fn the_underlying_measurement_counts_hard_links_once_and_stops_at_a_boundary() { let messages = test_messages(); diff --git a/src/i18n/en.rs b/src/i18n/en.rs index ba590ec7..6511b9df 100644 --- a/src/i18n/en.rs +++ b/src/i18n/en.rs @@ -641,7 +641,7 @@ pub(super) static STRINGS: Strings = Strings { audit_reserve_no_steps: "No commands are rendered for this state. Running part of the sequence would end at the step that mounts a filesystem over what is already there.", audit_reserve_no_steps_unremediable: "No command is rendered for this state either, and nothing is being withheld: what is outstanding above is at a path bootroot neither removes nor replaces, because what is there may be the only copy of something. It is named and left exactly as it was. Resolve it by hand, then run `{command}` again.", audit_reserve_note_stacked: "Note: more than one mount entry names {store}. The active mount is the last of them; the ones beneath it are hidden and are not what any reader reaches.", - audit_reserve_finding_store_not_empty: "{store} already holds content on the underlying filesystem. Mounting the reserve over it would hide every byte of it from every reader, this verification included, and what is hidden may be the only copy of records written before the reserve existed. Nothing was deleted, moved or mounted over. There are two supported ways forward: relocate those records to the reserve — a separate procedure this build does not provide — or set `audit_store_enforcement = \"directory\"` and run without a kernel-enforced ceiling.", + audit_reserve_finding_store_not_empty: "{store} already holds content on the underlying filesystem. On a live `bootroot infra up`, bring-up is refused before the stack starts, so no container starts. `create_host_path: false` governs creation only: if the underlying {store}/openbao already exists and the mount fails at boot, Compose still binds it and the audit device writes on the root filesystem. This exposure remains until the records are relocated onto the reserve. Mounting the reserve over the store would hide every byte of it from every reader, this verification included, and what is hidden may be the only copy of records written before the reserve existed. Nothing was deleted, moved or mounted over. There are two supported ways forward: relocate those records to the reserve — a separate procedure this build does not provide — or set `audit_store_enforcement = \"directory\"` and run without a kernel-enforced ceiling.", audit_reserve_finding_image_absent: "The loopback image {path} does not exist.", audit_reserve_finding_image_sparse: "The loopback image {path} is sparse: {missing} of its {size} bytes are not allocated. A sparse image passes every size check and still lets the writers consume the root filesystem lazily as records arrive.", audit_reserve_finding_image_owner: "The loopback image {path} is owned by uid {found} rather than uid {expected}. A principal that can rewrite the image can rewrite the audit filesystem underneath both writers.", diff --git a/src/i18n/ko.rs b/src/i18n/ko.rs index 9dd73d6e..1e9be36e 100644 --- a/src/i18n/ko.rs +++ b/src/i18n/ko.rs @@ -641,7 +641,7 @@ pub(super) static STRINGS: Strings = Strings { audit_reserve_no_steps: "이 상태에서는 어떤 명령도 렌더링하지 않습니다. 일부만 실행하면 이미 있는 내용 위로 파일 시스템을 마운트하는 단계에서 끝나기 때문입니다.", audit_reserve_no_steps_unremediable: "이 상태에서도 렌더링되는 명령은 없으며, 보류된 것도 없습니다. 위에 남아 있는 항목은 bootroot가 지우지도 바꾸지도 않는 경로에 있습니다. 그 자리의 대상이 어떤 것의 유일한 사본일 수 있기 때문입니다. 그래서 이름만 밝히고 있는 그대로 두었습니다. 직접 해결한 뒤 `{command}`을(를) 다시 실행하세요.", audit_reserve_note_stacked: "참고: {store}을(를) 가리키는 마운트 항목이 둘 이상입니다. 활성 마운트는 그중 마지막이며, 그 아래의 것들은 가려져 어떤 판독기도 닿지 않습니다.", - audit_reserve_finding_store_not_empty: "{store}이(가) 하부 파일 시스템에서 이미 내용을 담고 있습니다. 그 위로 예약량을 마운트하면 이 검증을 포함한 모든 판독기로부터 모든 바이트가 가려지며, 가려진 것이 예약량이 생기기 전에 기록된 레코드의 유일한 사본일 수 있습니다. 아무것도 삭제·이동되지 않았고 그 위로 마운트되지도 않았습니다. 지원되는 두 가지 길이 있습니다. 그 레코드를 예약량으로 옮기거나(이 빌드가 제공하지 않는 별도 절차입니다), `audit_store_enforcement = \"directory\"`로 설정하고 커널 강제 상한 없이 운용하는 것입니다.", + audit_reserve_finding_store_not_empty: "{store}이(가) 하부 파일 시스템에서 이미 내용을 담고 있습니다. 실제 `bootroot infra up`에서는 스택을 시작하기 전에 기동이 거부되어 어떤 컨테이너도 시작되지 않습니다. `create_host_path: false`는 생성만 통제합니다. 하부 {store}/openbao가 이미 있고 부팅 때 마운트가 실패하면 Compose는 여전히 그 디렉터리를 바인드하고 audit device는 루트 파일 시스템에 기록합니다. 이 노출은 레코드를 예약량으로 옮길 때까지 남습니다. 그 위로 예약량을 마운트하면 이 검증을 포함한 모든 판독기로부터 모든 바이트가 가려지며, 가려진 것이 예약량이 생기기 전에 기록된 레코드의 유일한 사본일 수 있습니다. 아무것도 삭제·이동되지 않았고 그 위로 마운트되지도 않았습니다. 지원되는 두 가지 길이 있습니다. 그 레코드를 예약량으로 옮기거나(이 빌드가 제공하지 않는 별도 절차입니다), `audit_store_enforcement = \"directory\"`로 설정하고 커널 강제 상한 없이 운용하는 것입니다.", audit_reserve_finding_image_absent: "루프백 이미지 {path}이(가) 존재하지 않습니다.", audit_reserve_finding_image_sparse: "루프백 이미지 {path}이(가) 희소(sparse)합니다. {size}바이트 중 {missing}바이트가 할당되어 있지 않습니다. 희소 이미지는 모든 크기 검사를 통과하면서도, 레코드가 도착하는 대로 기록자들이 루트 파일 시스템을 잠식하게 둡니다.", audit_reserve_finding_image_owner: "루프백 이미지 {path}의 소유자가 uid {expected}이(가) 아니라 uid {found}입니다. 이미지를 다시 쓸 수 있는 주체는 두 기록자 아래의 감사 파일 시스템을 다시 쓸 수 있습니다.",