diff --git a/dag/extdeps/filesystem/filesystem_io.dag b/dag/extdeps/filesystem/filesystem_io.dag index 0a13567056c..b636f4e59bc 100644 --- a/dag/extdeps/filesystem/filesystem_io.dag +++ b/dag/extdeps/filesystem/filesystem_io.dag @@ -208,7 +208,7 @@ fn filesystem_link_create_new( } } -data filesystem_read_outcome_adoption_standing: String ="RUNG: mitigatable. filesystem_read_outcome provides the single modeled fold from Filesystem.Read's scalar transport observation into FilesystemReadSucceeded | FilesystemReadRefused, but nothing forces callers through it. gunbc.ci_yaml_validate is the first converted consumer and preserves read refusal separately from YAML parse refusal. The raw operation remains directly consumed elsewhere, so content+success+error nonsense combinations remain writable at those sites. SUBJECT: an unconverted consumption site is a Filesystem Read call whose bound result has any success, error, or content projection outside the three arguments of filesystem_read_outcome. The call and those three projections remain after conversion because they supply the fold; adoption changes where they are consumed, not whether they exist. BASELINE measured on origin/main b21b710d5378387ae0c841f07323292c5d72faba: 92 unconverted consumption sites across 48 .dag files. REMAINDER after the first conversion: 91 unconverted consumption sites across 47 files. RE-DERIVATION: enumerate Filesystem Read assignment calls in tracked .dag source, excluding this adoption-standing data declaration so the instrument cannot count its own prose; for each bound result, classify it converted only when every success, error, and content projection is an argument of filesystem_read_outcome, otherwise classify it unconverted; count unconverted rows and distinct paths. NEXT-RUNG TRIGGER: the unconverted population reaches zero -- every Filesystem Read result projection occurs only as an argument to filesystem_read_outcome. The compiler-only filesystem_read intrinsic is a separate replacement migration and is not part of this population or this fold." +data filesystem_read_outcome_adoption_standing: String ="RUNG: mitigatable. filesystem_read_outcome provides the single modeled fold from Filesystem.Read's scalar transport observation into FilesystemReadSucceeded | FilesystemReadRefused, but nothing forces callers through it. gunbc.ci_yaml_validate is the first converted consumer and preserves read refusal separately from YAML parse refusal. The raw operation remains directly consumed elsewhere, so content+success+error nonsense combinations remain writable at those sites. SUBJECT: an unconverted consumption site is a Filesystem Read call whose bound result has any success, error, or content projection outside the three arguments of filesystem_read_outcome. The call and those three projections remain after conversion because they supply the fold; adoption changes where they are consumed, not whether they exist. CLASSES EXCLUDED FROM THE TARGET POPULATION, recorded so exclusion is a decision and not a blind spot: a site whose read projections already flow through this fold's own typed shape is exact_read_typed and is not a target; a site that routes a raw read through a domain-specific fold of the same shape -- gunbc.machine_intake proc_read_outcome into ProcRead | ProcReadRefused, which additionally carries error_kind -- is domain_read_fold: it is a typed outcome, not a silent default, but a second read-outcome fold beside this one is a DESIGN 3 fork, recorded to be unified later by growing the shared fold, and those sites are targets of that unification, not of this lane. BASELINE measured on origin/main b21b710d5378387ae0c841f07323292c5d72faba: 92 unconverted consumption sites across 48 .dag files. REMAINDER after the first conversion: 91 unconverted consumption sites across 47 files. RE-DERIVATION: enumerate Filesystem Read assignment calls in tracked .dag source, excluding this adoption-standing data declaration so the instrument cannot count its own prose; for each bound result, classify it converted only when every success, error, and content projection is an argument of filesystem_read_outcome, otherwise classify it unconverted; count unconverted rows and distinct paths. NEXT-RUNG TRIGGER: the unconverted population reaches zero -- every Filesystem Read result projection occurs only as an argument to filesystem_read_outcome. The compiler-only filesystem_read intrinsic is a separate replacement migration and is not part of this population or this fold." data filesystem_absence_establishment_adoption_standing: String = "RUNG: structurally guaranteed for the consumers that route through filesystem_file_observation, on the source-to-.dag acceptance path only, and MITIGATABLE NOWHERE ELSE -- the raw Filesystem.Read and Filesystem.List operations stay callable, so a module that has not adopted the carrier can still write the conflation. This row states the unconverted population at identity grain rather than as a count, because a count is not a plan and a one-sided ratchet over a number measured on the current tree is the oracle section 5 rejects. SUBJECT: a site that concludes ABSENCE, NOT-PRESENT or a dropped element from a FAILED read or a FAILED boolean path test, rather than from a listing that succeeded. It is not every Filesystem.Read consumer -- most read a file they already know exists, and those are the separate filesystem_read_outcome adoption population above. ENUMERATED SITES, each one read rather than pattern-matched: the roster is EMPTY as of this row's restoration. The six identities the row carried on origin/main 6305a5174c all route through the carrier now -- gunbc.host_effect_nbd_proxy_serve host_effect_nbd_proxy_serve_read_session_token, v2.workflow.product_receipt_stage run_product_receipt_stage, tools.merge_admission_walk read_tested_subject and read_floor_receipt, gunbc.codex_supervised_turn codex_supervised_turn_generation_observation, and tools.opaque_realization_census declaration_body_standing -- the five converted by the change that empties this roster -- each preserving could-not-look as its own typed refusal rather than an absence, and gunbc.fleet_converge_plan_cli observe_cap_members_wet already converted on main -- so a could-not-look at any of them can no longer masquerade as an established absence. THE ROW STAYS NONE THE LESS, and an empty roster is not a deleted row: the raw operations remain callable outside this module, so the class shape can be re-spelled at a new site at any time, and what the row guards is the CLASS, not its former members. A site of this shape found anywhere is a defect to repair by adoption, not a row to add. The mistake this restoration repairs was deleting the row because its enumeration emptied rather than because the trigger below expired, which is exactly the failure the NEXT-RUNG TRIGGER paragraph exists to prevent. WHAT IS DELIBERATELY NOT ON THE LIST: gunbc.roadmap_belt_actuate belt_read_or_empty, whose collapse is scoped and argued in the annotation above its definition -- both branches take the same action at every remaining caller -- and gunbc.fabric_cell_acquire, which already establishes absence from the parent enumeration through its own four-state observation and would gain nothing but a second spelling. MONOTONE DIRECTION: the roster above may only shrink. A row leaves it when the site routes through filesystem_file_observation or filesystem_entry_presence, or when the site is deleted; a NEW site of this shape is a defect to repair rather than a row to add. NEXT-RUNG TRIGGER, and it names the capability rather than an artifact: the raw List and Read result projections cease to be reachable outside this module's folds, so a consumer cannot spell the conflation at all -- at which point the enumeration above has no subject and this row is deleted. That is the same trigger filesystem_read_outcome_adoption_standing carries, one question further in: it asks that every read projection be folded, this asks that every ABSENCE be established." diff --git a/dag/gunbc/auth/access_token_source.dag b/dag/gunbc/auth/access_token_source.dag index 50d5e314452..cbe4dd2c9a4 100644 --- a/dag/gunbc/auth/access_token_source.dag +++ b/dag/gunbc/auth/access_token_source.dag @@ -14,7 +14,13 @@ import std.upsert_decision { UpsertClassification, } import extdeps.shell -import extdeps.filesystem.filesystem_io { Filesystem } +import extdeps.filesystem.filesystem_io { + Filesystem, + FilesystemReadOutcome, + FilesystemReadRefused, + FilesystemReadSucceeded, + filesystem_read_outcome, +} import extdeps.cloud.gcp.auth_print_access_token { gcloud_stderr_auth_deficit, } @@ -202,19 +208,21 @@ type SuppliedTokenResolution = SuppliedTokenReady { token: Secret } | SuppliedTokenUnavailable { cause: NonEmptyStr } -fn classify_supplied_token(success: Bool, error: String, content: String) -> SuppliedTokenResolution { - if success == false { - SuppliedTokenUnavailable { - cause: join(["the access token file could not be read: ", error], "") as NonEmptyStr, - } - } else { - let token = trim(s: content) - if token == "" { +fn classify_supplied_token(read: FilesystemReadOutcome) -> SuppliedTokenResolution { + match read { + FilesystemReadRefused { error } => SuppliedTokenUnavailable { - cause: "the access token file is empty, which is never a usable bearer token" as NonEmptyStr, + cause: join(["the access token file could not be read: ", error], "") as NonEmptyStr, + } + FilesystemReadSucceeded { content } => { + let token = trim(s: content) + if token == "" { + SuppliedTokenUnavailable { + cause: "the access token file is empty, which is never a usable bearer token" as NonEmptyStr, + } + } else { + SuppliedTokenReady { token: token as Secret } } - } else { - SuppliedTokenReady { token: token as Secret } } } } @@ -238,7 +246,7 @@ fn read_supplied_access_token() -> SuppliedTokenResolution { } } else { let read = Filesystem.Read(path: path) - classify_supplied_token(success: read.success, error: read.error, content: read.content) + classify_supplied_token(read: filesystem_read_outcome(content: read.content, success: read.success, error: read.error)) } } } diff --git a/dag/gunbc/auth/approval_gate.dag b/dag/gunbc/auth/approval_gate.dag index ffa4126b0ec..a73957f1d52 100644 --- a/dag/gunbc/auth/approval_gate.dag +++ b/dag/gunbc/auth/approval_gate.dag @@ -8,7 +8,13 @@ import std.scoped_authorization { AuthorizationRequest, ScopedAuthorization, authorize, AuthorizationPermitted, AuthorizationRefused, authorization_refusal_reason, } import extdeps.clock { Clock } -import extdeps.filesystem.filesystem_io { Filesystem } +import extdeps.filesystem.filesystem_io { + Filesystem, + FilesystemReadOutcome, + FilesystemReadRefused, + FilesystemReadSucceeded, + filesystem_read_outcome, +} import extdeps.http.client import extdeps.tools.sleep { sleep_delay_seconds_second_carrier_projection } import extdeps.github.actions_environment { github_runner_temp_variable_name } @@ -252,10 +258,11 @@ fn approval_file_stored_request(stored: StoredApprovalRequest, body_file_name: S ApprovalFilingRefused { reason: join([approval_submission_mac_key_path_env as String, " is unset; the request cannot be filed"], "") } } else { let key_read = Filesystem.Read(path: sub_path) - if !key_read.success { - ApprovalFilingRefused { reason: join(["submission MAC key unreadable: ", key_read.error], "") } - } else { - match sign_stored_request(key_material: trim(s: key_read.content), request: stored) { + match filesystem_read_outcome(content: key_read.content, success: key_read.success, error: key_read.error) { + FilesystemReadRefused { error } => + ApprovalFilingRefused { reason: join(["submission MAC key unreadable: ", error], "") } + FilesystemReadSucceeded { content } => + match sign_stored_request(key_material: trim(s: content), request: stored) { SubmissionMacKeyNotHex { key_id: k } => ApprovalFilingRefused { reason: join(["submission MAC key is not hex: ", k as String], "") } SubmissionMacReady { tag_hex: tag } => { let tmp = actions_variable_trimmed(name: github_runner_temp_variable_name as NonEmptyStr) @@ -274,6 +281,6 @@ fn approval_file_stored_request(stored: StoredApprovalRequest, body_file_name: S } } } + } } } -} diff --git a/dag/gunbc/auth/approval_keyring_converge.dag b/dag/gunbc/auth/approval_keyring_converge.dag index c951fdd6322..1b6d9319c96 100644 --- a/dag/gunbc/auth/approval_keyring_converge.dag +++ b/dag/gunbc/auth/approval_keyring_converge.dag @@ -9,7 +9,12 @@ import gunbc.auth.approval_store_receipt { approval_store_receipt_mac_key_path } import gunbc.auth.approval_request_submission { approval_submission_mac_key_path, } -import extdeps.filesystem.filesystem_io { Filesystem } +import extdeps.filesystem.filesystem_io { + Filesystem, + FilesystemReadRefused, + FilesystemReadSucceeded, + filesystem_read_outcome, +} import extdeps.tools.sha256sum { sha256sum_file_digest_via_shell, Sha256FileDigest, Sha256FileDigestUnavailable } import extdeps.sudo.elevation { sudo_elevate } import extdeps.tools.chown { chown_argv, ChownOwnerNames } @@ -244,59 +249,67 @@ fn approval_keyring_converge_on_host() -> ProcessExit let cap = Filesystem.Read(path: cap_path) let sub = Filesystem.Read(path: sub_path) let rcpt = Filesystem.Read(path: rcpt_path) - if !rcpt.success { - exit_failure(reason: concat("approval keyring: staged store receipt MAC key unreadable: ", rcpt.error)) - } else if !cap.success { - exit_failure(reason: concat("approval keyring: staged capability MAC key unreadable: ", cap.error)) - } else if !sub.success { - exit_failure(reason: concat("approval keyring: staged submission MAC key unreadable: ", sub.error)) - } else { - match approval_keyring_staged_digest_ok(path: cap_path as NonEmptyStr, expected_prefix: approval_mac_key_payload_sha256_prefix, role: "capability") { - ExitSuccess => - match approval_keyring_staged_digest_ok(path: sub_path as NonEmptyStr, expected_prefix: approval_submission_mac_key_payload_sha256_prefix, role: "submission") { - ExitSuccess => - match approval_keyring_staged_digest_ok(path: rcpt_path as NonEmptyStr, expected_prefix: approval_store_receipt_mac_key_payload_sha256_prefix, role: "store-receipt") { - ExitSuccess => - match prepare_fleet_ssh_agent_context(attempt_raw: approval_keyring_converge_attempt_raw) { - FleetSshContextRefused { cause: c } => exit_failure(reason: concat("approval keyring: ", c)) - FleetSshContextReady { context: context, receipt: _ } => { - let target = keyring_ssh_target() - match keyring_elevated_exec(target: target, context: context, command: ["true"], step: "administrator-privilege") { - ExitSuccess => - match keyring_ensure_dirs(target: target, context: context) { - ExitSuccess => - match keyring_install_file(target: target, context: context, dest: approval_mac_key_path, content: cap.content, step: "capability-key") { - ExitSuccess => - match keyring_install_file(target: target, context: context, dest: approval_submission_mac_key_path, content: sub.content, step: "submission-key") { - ExitSuccess => - match keyring_install_file(target: target, context: context, dest: approval_store_receipt_mac_key_path, content: rcpt.content, step: "store-receipt-key") { - ExitSuccess => - match keyring_elevated_exec(target: target, context: context, command: ntfy_group_admit_argv(), step: "ntfy-group-admit") { - ExitSuccess => - match keyring_elevated_exec(target: target, context: context, command: roadmap_unit_restart_argv(), step: "roadmap-unit-restart") { - ExitSuccess => ExitSuccess - other => other - } - other => other - } - other => other + match filesystem_read_outcome(content: rcpt.content, success: rcpt.success, error: rcpt.error) { + FilesystemReadRefused { error } => + exit_failure(reason: concat("approval keyring: staged store receipt MAC key unreadable: ", error)) + FilesystemReadSucceeded { content: rcpt_content } => + match filesystem_read_outcome(content: cap.content, success: cap.success, error: cap.error) { + FilesystemReadRefused { error } => + exit_failure(reason: concat("approval keyring: staged capability MAC key unreadable: ", error)) + FilesystemReadSucceeded { content: cap_content } => + match filesystem_read_outcome(content: sub.content, success: sub.success, error: sub.error) { + FilesystemReadRefused { error } => + exit_failure(reason: concat("approval keyring: staged submission MAC key unreadable: ", error)) + FilesystemReadSucceeded { content: sub_content } => + match approval_keyring_staged_digest_ok(path: cap_path as NonEmptyStr, expected_prefix: approval_mac_key_payload_sha256_prefix, role: "capability") { + ExitSuccess => + match approval_keyring_staged_digest_ok(path: sub_path as NonEmptyStr, expected_prefix: approval_submission_mac_key_payload_sha256_prefix, role: "submission") { + ExitSuccess => + match approval_keyring_staged_digest_ok(path: rcpt_path as NonEmptyStr, expected_prefix: approval_store_receipt_mac_key_payload_sha256_prefix, role: "store-receipt") { + ExitSuccess => + match prepare_fleet_ssh_agent_context(attempt_raw: approval_keyring_converge_attempt_raw) { + FleetSshContextRefused { cause: c } => + exit_failure(reason: concat("approval keyring: ", c)) + FleetSshContextReady { context: context, receipt: _ } => { + let target = keyring_ssh_target() + match keyring_elevated_exec(target: target, context: context, command: ["true"], step: "administrator-privilege") { + ExitSuccess => + match keyring_ensure_dirs(target: target, context: context) { + ExitSuccess => + match keyring_install_file(target: target, context: context, dest: approval_mac_key_path, content: cap_content, step: "capability-key") { + ExitSuccess => + match keyring_install_file(target: target, context: context, dest: approval_submission_mac_key_path, content: sub_content, step: "submission-key") { + ExitSuccess => + match keyring_install_file(target: target, context: context, dest: approval_store_receipt_mac_key_path, content: rcpt_content, step: "store-receipt-key") { + ExitSuccess => + match keyring_elevated_exec(target: target, context: context, command: ntfy_group_admit_argv(), step: "ntfy-group-admit") { + ExitSuccess => + match keyring_elevated_exec(target: target, context: context, command: roadmap_unit_restart_argv(), step: "roadmap-unit-restart") { + ExitSuccess => ExitSuccess + other => other + } + other => other + } + other => other + } + other => other + } + other => other + } + other => other + } + other => other + } } - other => other - } - other => other - } - other => other - } - other => other - } + } + other => other + } + other => other + } + other => other } - } - other => other - } - other => other - } - other => other - } + } + } } } } diff --git a/dag/gunbc/auth/ci_app_key_rotation.dag b/dag/gunbc/auth/ci_app_key_rotation.dag index 1e863064425..b5894fbb03c 100644 --- a/dag/gunbc/auth/ci_app_key_rotation.dag +++ b/dag/gunbc/auth/ci_app_key_rotation.dag @@ -3,7 +3,17 @@ module gunbc.auth.ci_app_key_rotation import std.types { String, NonEmptyStr, Bool, Int, List } import std.process { ProcessExit, ExitSuccess, exit_failure } import extdeps.shell -import extdeps.filesystem.filesystem_io { Filesystem } +import extdeps.filesystem.filesystem_io { + Filesystem, + FilesystemFileObservation, + FilesystemFileRead, + FilesystemFileAbsent, + FilesystemFileIndeterminate, + FilesystemFileObservationsDisagree, + FilesystemFileSubjectRefused, + filesystem_established_absence_detail, +} +import gunbc.filesystem_file_observe { filesystem_file_observation_of_path } import gunbc.auth.github_gcp_federation { fleet_secrets_project_number } import extdeps.cloud.gcp.secret_ref { SecretRef, secret_ref_version_resource, @@ -145,6 +155,8 @@ type AppKeyVersionRefusal | AppKeyVersionSelectorIsAlias | AppKeyVersionSelectorNotANumber { supplied: String } | AppKeyMintObservationAbsent { cause: NonEmptyStr } + | AppKeyMintObservationMissing { cause: NonEmptyStr } + | AppKeyMintObservationUnreadable { cause: NonEmptyStr } | AppKeyVersionUnreadable { http_status: String } | AppKeyResolvedVersionUndecodable { cause: SmResolvedVersionIdentityRefusal } | AppKeyResolvedVersionDiffers { requested: String, resolved: String } @@ -306,6 +318,10 @@ fn app_key_refusal_text(cause: AppKeyVersionRefusal) -> String { join(["AppKeyVersionSelectorNotANumber: '", s, "' is not a Secret Manager version number"], "") AppKeyMintObservationAbsent { cause: c } => concat("AppKeyMintObservationAbsent: ", c as String) + AppKeyMintObservationMissing { cause: c } => + concat("AppKeyMintObservationMissing: ", concat(c as String, ": the mint step was to write an observation record at the supplied path but no record is there; re-run the mint step and check it reported success")) + AppKeyMintObservationUnreadable { cause: c } => + concat("AppKeyMintObservationUnreadable: ", concat(c as String, ": the observation record's content could not be obtained from the supplied path, which is a different failure from there being no record; inspect the record path and its permissions")) AppKeyVersionUnreadable { http_status: s } => join([ "AppKeyVersionUnreadable: Secret Manager answered HTTP ", s, @@ -381,17 +397,41 @@ fn env_or_empty(name: NonEmptyStr) -> String { } } -fn observe_app_key_mint_record() -> AppKeyMintObservation? { +// The record read is a typed outcome, never a silently defaulted value (DESIGN ยง5). The facts a +// supplied record path can produce are kept apart: the record is absent (the directory listing +// establishes that nothing is there -- the mint step wrote no record), the record's content could +// not be obtained (a refused read establishes nothing about existence by itself), and the record +// decoded. The presence claim is only ever licensed by #12982's observation authority, never +// inferred from a failed read -- DP-M5's defect is mirrored here if a refused read is allowed to +// speak about what exists. +type AppKeyMintRecordRead + = AppKeyMintRecordDecoded { observation: AppKeyMintObservation } + | AppKeyMintRecordMissing { cause: NonEmptyStr } + | AppKeyMintRecordUnread { cause: NonEmptyStr } + +fn app_key_mint_record_from_observation(observation: FilesystemFileObservation) -> AppKeyMintRecordRead { + match observation { + FilesystemFileRead { path: _, content: content } => + AppKeyMintRecordDecoded { observation: decode_app_key_mint_record(record: content) } + FilesystemFileAbsent(absence) => + AppKeyMintRecordMissing { + cause: concat("the record path was supplied but ", filesystem_established_absence_detail(absence: absence)) as NonEmptyStr, + } + FilesystemFileIndeterminate { cause: c } => + AppKeyMintRecordUnread { cause: c as NonEmptyStr } + FilesystemFileObservationsDisagree { path: _, cause: c } => + AppKeyMintRecordUnread { cause: c as NonEmptyStr } + FilesystemFileSubjectRefused { directory: _, name: _, cause: c } => + AppKeyMintRecordUnread { cause: concat("the record path was refused before any filesystem call ran: ", c) as NonEmptyStr } + } +} + +fn observe_app_key_mint_record() -> AppKeyMintRecordRead? { let path = env_or_empty(name: app_key_verify_record_env_name) if path == "" { none } else { - let read = Filesystem.Read(path: path) - if !read.success { - none - } else { - Present { value: decode_app_key_mint_record(record: read.content) } - } + Present { value: app_key_mint_record_from_observation(observation: filesystem_file_observation_of_path(path: path)) } } } @@ -405,9 +445,14 @@ fn ci_app_key_version_verify_wet() -> ProcessExit { match observe_app_key_mint_record() { Absent => AppKeyVersionRefused { - cause: AppKeyMintObservationAbsent { cause: "the mint step's observation record is absent or unreadable" as NonEmptyStr }, + cause: AppKeyMintObservationAbsent { cause: concat("the mint step recorded no observation record for this run: ", concat(app_key_verify_record_env_name as String, " is unset")) as NonEmptyStr }, } - Present { value: obs } => verify_app_key_version(secret: ci_app_key_contract.subject, version: n, observation: obs) + Present { value: AppKeyMintRecordMissing { cause: c } } => + AppKeyVersionRefused { cause: AppKeyMintObservationMissing { cause: c } } + Present { value: AppKeyMintRecordUnread { cause: c } } => + AppKeyVersionRefused { cause: AppKeyMintObservationUnreadable { cause: c } } + Present { value: AppKeyMintRecordDecoded { observation: obs } } => + verify_app_key_version(secret: ci_app_key_contract.subject, version: n, observation: obs) } } let wrote = Filesystem.Write( diff --git a/dag/gunbc/auth/credentials.dag b/dag/gunbc/auth/credentials.dag index 21fe94fa600..11543df8ac3 100644 --- a/dag/gunbc/auth/credentials.dag +++ b/dag/gunbc/auth/credentials.dag @@ -2,7 +2,13 @@ module gunbc.auth.credentials import extdeps.shell import extdeps.cloud.gcp.gcp { GcpOAuth2AccessTokenStrategy } -import extdeps.filesystem.filesystem_io { Filesystem } +import extdeps.filesystem.filesystem_io { + Filesystem, + FilesystemReadOutcome, + FilesystemReadRefused, + FilesystemReadSucceeded, + filesystem_read_outcome, +} import extdeps.cloud.gcp.adc_document { decode_adc_document, AdcDocumentDecoded, @@ -74,16 +80,16 @@ pattern gcp_oauth_access_token_via_adc_refresh( uses net: std.resources.Network { node read = Filesystem.Read(path: adc_path as String) - return match read.success { - true => - match decode_adc_document(document: read.content as Secret) { + return match filesystem_read_outcome(content: read.content, success: read.success, error: read.error) { + FilesystemReadSucceeded { content: content } => + match decode_adc_document(document: content as Secret) { AdcDocumentDecoded { credentials: credentials } => gcp_adc_token_from_credentials(credentials: credentials) AdcDocumentDecodeRefused { cause: cause } => GcpAdcDocumentRefused { path: adc_path as String, cause: cause } } - false => - GcpAdcReadRefused { path: adc_path as String, cause: read.error } + FilesystemReadRefused { error: error } => + GcpAdcReadRefused { path: adc_path as String, cause: error } } } diff --git a/dag/gunbc/auth/profile_projection.dag b/dag/gunbc/auth/profile_projection.dag index b88d1b6d979..d564601c675 100644 --- a/dag/gunbc/auth/profile_projection.dag +++ b/dag/gunbc/auth/profile_projection.dag @@ -10,6 +10,7 @@ import extdeps.filesystem.filesystem_io { filesystem_file_observation, FilesystemFileAbsent, FilesystemFileRead, FilesystemFileIndeterminate, FilesystemFileObservationsDisagree, FilesystemFileSubjectRefused, + FilesystemReadOutcome, FilesystemReadRefused, FilesystemReadSucceeded, } import extdeps.languages.json.emit { serialize_json, json_object, json_kv, json_string, JsonValue, @@ -374,19 +375,22 @@ fn profile_store_decode_entries( { profiles: acc, skipped: skipped_acc } } else { let read_result = Filesystem.Read(path: join([layout.root as String, "/", entry], "")) - if !read_result.success { - { profiles: acc, skipped: concat(skipped_acc, [ProfileStoreSkipped { entry: entry, reason: join(["read failed: ", read_result.error], "") }]) } - } else { - match principal_profile_of_json(text: read_result.content) { - ProfileFileMalformed { detail } => { - let skipped_file = { profiles: acc, skipped: concat(skipped_acc, [ProfileStoreSkipped { entry: entry, reason: join(["malformed: ", detail], "") }]) } - skipped_file - } - ProfileFileDecoded { profile: p } => { - let decoded_file = { profiles: concat(acc, [p]), skipped: skipped_acc } - decoded_file - } + match filesystem_read_outcome(content: read_result.content, success: read_result.success, error: read_result.error) { + FilesystemReadRefused { error } => { + let refused_file = { profiles: acc, skipped: concat(skipped_acc, [ProfileStoreSkipped { entry: entry, reason: join(["read failed: ", error], "") }]) } + refused_file } + FilesystemReadSucceeded { content } => + match principal_profile_of_json(text: content) { + ProfileFileMalformed { detail } => { + let skipped_file = { profiles: acc, skipped: concat(skipped_acc, [ProfileStoreSkipped { entry: entry, reason: join(["malformed: ", detail], "") }]) } + skipped_file + } + ProfileFileDecoded { profile: p } => { + let decoded_file = { profiles: concat(acc, [p]), skipped: skipped_acc } + decoded_file + } + } } } profile_store_decode_entries(layout: layout, entries: entries.skip(n: 1), acc: stepped.profiles, skipped_acc: stepped.skipped) diff --git a/dag/test/claim/ci_app_key_rotation_witness_test.dag b/dag/test/claim/ci_app_key_rotation_witness_test.dag index 77d25ba9df6..1e7ba86c9fe 100644 --- a/dag/test/claim/ci_app_key_rotation_witness_test.dag +++ b/dag/test/claim/ci_app_key_rotation_witness_test.dag @@ -65,6 +65,7 @@ import gunbc.auth.ci_app_key_rotation { AppKeyVersionSelectorIsAlias, AppKeyVersionSelectorNotANumber, AppKeyMintObservationAbsent, + AppKeyMintObservationMissing, AppKeyVersionUnreadable, AppKeyResolvedVersionUndecodable, AppKeyResolvedVersionDiffers, @@ -78,6 +79,19 @@ import gunbc.auth.ci_app_key_rotation { decode_app_key_mint_record, verify_app_key_version, app_key_verdict_evidence, + app_key_mint_record_from_observation, + AppKeyMintRecordDecoded, + AppKeyMintRecordMissing, + AppKeyMintRecordUnread, + AppKeyMintObservationUnreadable, + app_key_refusal_text, +} +import extdeps.filesystem.filesystem_io { + FilesystemFileRead, + FilesystemFileIndeterminate, + FilesystemReadRefused, + filesystem_file_observation, + filesystem_listing_observation, } data live_tree_disposition: LiveTreeDisposition = SubstrateInputsOnly @@ -452,3 +466,84 @@ test fn the_contract_is_bound_to_the_app_key_and_its_restore_test_is_the_roster( Absent => false } } + +// --------------------------------------------------------------------------- +// The record read is a typed outcome (filesystem_read_outcome_adoption, gunbc/auth batch) +// --------------------------------------------------------------------------- + +// A witness's incidental helper, not a shared authority: three test files already declare their own +// `string_contains`, so a fourth would make the bare name ambiguous corpus-wide. +fn contains_text(haystack: String, needle: String) -> Bool { + haystack.contains(needle) +} + +// RED for the silent-none conversion: before the observation conversion, an unreadable observation +// record collapsed into the absent arm -- `!read.success` returned `none`, and the operator was +// told the record was "absent or unreadable", the two facts one text. This witness goes red on a +// tree without the conversion (the classifier it names does not exist there) and, once it exists, +// pins that an observation that could not be completed refuses as unreadable and carries the +// cause verbatim. The observation arm here is one where presence was never established -- the +// listing could not be interrogated -- so nothing in the pinned refusal may speak of what exists. +test fn an_unreadable_mint_record_is_refused_as_unreadable_never_collapsed_into_absent() -> Bool { + match app_key_mint_record_from_observation(observation: FilesystemFileIndeterminate { cause: "permission denied" }) { + AppKeyMintRecordUnread { cause: c } => (c as String) == "permission denied" + AppKeyMintRecordDecoded { observation: _ } => false + AppKeyMintRecordMissing { cause: _ } => false + } +} + +// Positive control over the same classifier: the success arm still decodes through the observation, +// so the red above cannot be satisfied by refusing everything. +test fn a_readable_mint_record_still_decodes_through_the_read_fold() -> Bool { + match app_key_mint_record_from_observation(observation: FilesystemFileRead { path: "/var/lib/gunbc/ci/app-key/observation.json", content: minted_record() }) { + AppKeyMintRecordDecoded { observation: obs } => + match obs.key_http_status { + Present { value: status } => (status as String) == "200" + Absent => false + } + AppKeyMintRecordUnread { cause: _ } => false + AppKeyMintRecordMissing { cause: _ } => false + } +} + +// MISSING-PATH CONTROL (side-chat finding: the permission-denied case cannot catch the overclaim). +// A record that is not there and a record whose content could not be obtained are two facts, and +// neither refusal text may claim existence: a failed look does not establish what is on the disk. +// The Missing and Unreadable refusal arms are constructed here directly; the classifier arm that +// maps the listing-established absence (FilesystemFileAbsent) onto Missing is driven separately +// through the owner's producers in an_established_absence_of_the_record_refuses_as_missing_... +// below -- the absence carrier is a sole_constructor of filesystem_io and is never forged here. +test fn a_missing_record_is_distinct_from_unreadable_and_claims_no_existence() -> Bool { + let missing = app_key_refusal_text(cause: AppKeyMintObservationMissing { cause: "the record path was supplied but the directory listing establishes absence" as NonEmptyStr }) + let unreadable = app_key_refusal_text(cause: AppKeyMintObservationUnreadable { cause: "permission denied" as NonEmptyStr }) + let absent = app_key_refusal_text(cause: AppKeyMintObservationAbsent { cause: "APP_KEY_VERIFY_RECORD is unset" as NonEmptyStr }) + contains_text(haystack: missing, needle: "no record is there") + && !contains_text(haystack: missing, needle: "exists") + && !contains_text(haystack: unreadable, needle: "exists") + && contains_text(haystack: unreadable, needle: "could not be obtained") + && contains_text(haystack: absent, needle: "AppKeyMintObservationAbsent:") + && ((missing as String) != (unreadable as String)) + && ((missing as String) != (absent as String)) + && ((unreadable as String) != (absent as String)) +} + +// MISSING-PATH CONTROL, DRIVEN THROUGH THE OWNER'S PRODUCERS (side-chat finding: constructing the +// Missing refusal directly exercises only the rendering). A succeeded listing that omits the record +// name plus a refused read establish the absence inside filesystem_io -- the sole_constructor +// carrier is produced there, never forged here -- and the full chain must hold: the fold yields +// FilesystemFileAbsent, the mint classifier maps it to AppKeyMintRecordMissing, the verify +// classifier maps that to AppKeyMintObservationMissing, and the operator-facing text names the +// absence and claims no existence. +test fn an_established_absence_of_the_record_refuses_as_missing_and_claims_no_existence() -> Bool { + let listing = filesystem_listing_observation(directory: "/var/lib/gunbc/ci/app-key", success: true, entries: "stale-agent-sock\nnote.txt", error: "unused on success") + let observed = filesystem_file_observation(listing: listing, name: "observation.json", path: "/var/lib/gunbc/ci/app-key/observation.json", read: FilesystemReadRefused { error: "permission denied" }) + match app_key_mint_record_from_observation(observation: observed) { + AppKeyMintRecordMissing { cause: c } => { + let text = app_key_refusal_text(cause: AppKeyMintObservationMissing { cause: c }) + contains_text(haystack: text, needle: "no record is there") + && !contains_text(haystack: text, needle: "exists") + } + AppKeyMintRecordDecoded { observation: _ } => false + AppKeyMintRecordUnread { cause: _ } => false + } +}