Skip to main content

shardline_fsck/
lib.rs

1#![deny(unsafe_code)]
2#![cfg_attr(
3    test,
4    allow(
5        clippy::unwrap_used,
6        clippy::expect_used,
7        clippy::indexing_slicing,
8        clippy::arithmetic_side_effects,
9        clippy::shadow_unrelated,
10        clippy::let_underscore_must_use,
11        clippy::format_push_string
12    )
13)]
14
15//! Storage integrity checking logic for the Shardline server ecosystem.
16//!
17//! This crate provides pure fsck functions that operate on explicit
18//! store parameters rather than server configuration.
19
20mod lifecycle_checks;
21mod record_checks;
22
23use std::{
24    collections::HashSet,
25    fmt::{Display, Formatter, Result as FmtResult},
26    io::Error as IoError,
27    num::TryFromIntError,
28    path::{Path, PathBuf},
29};
30
31use shardline_index::{
32    AsyncIndexStore, FileRecord, FileRecordInvariantError, LocalIndexStoreError,
33    MemoryIndexStoreError, MemoryRecordStoreError, PostgresMetadataStoreError, xet_hash_hex_string,
34};
35use shardline_protocol::HashParseError;
36use shardline_server_core::{
37    InvalidSerializedShardError, OpsRecordKind, OpsRecordStore, ServerObjectStore,
38    ServerObjectStoreError, ShardMetadataLimits, checked_increment, read_full_object,
39};
40use shardline_storage::{ObjectKey, ObjectStore};
41use shardline_xet_adapter::{XetAdapterError, XorbParseError, retained_shard_chunk_hashes};
42use thiserror::Error;
43
44use lifecycle_checks::inspect_lifecycle_metadata;
45use record_checks::scan_record_tree;
46
47/// Fsck operation failure.
48#[derive(Debug, Error)]
49pub enum FsckError {
50    /// A local filesystem I/O error occurred.
51    #[error("local storage operation failed")]
52    Io(#[from] IoError),
53    /// JSON serialization or deserialization failed.
54    #[error("json operation failed")]
55    Json(#[from] serde_json::Error),
56    /// Numeric conversion exceeded supported bounds.
57    #[error("numeric conversion exceeded supported bounds")]
58    NumericConversion(#[from] TryFromIntError),
59    /// Arithmetic overflowed a checked bound.
60    #[error("arithmetic overflow")]
61    Overflow,
62    /// Local storage adapter access failed.
63    #[error("local storage adapter operation failed")]
64    LocalObjectStore(#[from] shardline_storage::LocalObjectStoreError),
65    /// S3-compatible object-storage adapter access failed.
66    #[error("s3 object storage adapter operation failed")]
67    S3ObjectStore(#[from] shardline_storage::S3ObjectStoreError),
68    /// Object-store backend error.
69    #[error("object store operation failed")]
70    ObjectStore(#[from] ServerObjectStoreError),
71    /// Xet adapter access failed.
72    #[error("xet adapter operation failed")]
73    XetAdapter(#[from] XetAdapterError),
74    /// Local index adapter access failed.
75    #[error("local index adapter operation failed")]
76    LocalIndexStore(#[from] LocalIndexStoreError),
77    /// In-memory index adapter access failed.
78    #[error("memory index adapter operation failed")]
79    MemoryIndexStore(#[from] MemoryIndexStoreError),
80    /// In-memory record adapter access failed.
81    #[error("memory record adapter operation failed")]
82    MemoryRecordStore(#[from] MemoryRecordStoreError),
83    /// Postgres metadata adapter access failed.
84    #[error("postgres metadata adapter operation failed")]
85    PostgresMetadata(#[from] PostgresMetadataStoreError),
86    /// Stored file metadata exceeded the bounded parser ceiling.
87    #[error("stored file metadata exceeded the bounded parser ceiling")]
88    StoredFileMetadataTooLarge {
89        /// Observed file length in bytes.
90        observed_bytes: u64,
91        /// Maximum accepted file length in bytes.
92        maximum_bytes: u64,
93    },
94}
95
96impl From<shardline_server_core::ParseStoredFileRecordError> for FsckError {
97    fn from(value: shardline_server_core::ParseStoredFileRecordError) -> Self {
98        match value {
99            shardline_server_core::ParseStoredFileRecordError::StoredFileMetadataTooLarge {
100                observed_bytes,
101                maximum_bytes,
102            } => Self::StoredFileMetadataTooLarge {
103                observed_bytes,
104                maximum_bytes,
105            },
106            shardline_server_core::ParseStoredFileRecordError::Json(e) => Self::Json(e),
107        }
108    }
109}
110
111impl From<shardline_server_core::ValidateIdentifierError> for FsckError {
112    fn from(_: shardline_server_core::ValidateIdentifierError) -> Self {
113        Self::Overflow
114    }
115}
116
117impl From<shardline_server_core::ValidateContentHashError> for FsckError {
118    fn from(_: shardline_server_core::ValidateContentHashError) -> Self {
119        Self::Overflow
120    }
121}
122
123impl From<shardline_server_core::RebuildOverflowError> for FsckError {
124    fn from(_: shardline_server_core::RebuildOverflowError) -> Self {
125        Self::Overflow
126    }
127}
128
129impl From<XorbParseError> for FsckError {
130    fn from(_: XorbParseError) -> Self {
131        Self::Overflow
132    }
133}
134
135impl From<HashParseError> for FsckError {
136    fn from(_: HashParseError) -> Self {
137        Self::Overflow
138    }
139}
140
141/// Integrity-check report.
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct FsckReport {
144    /// Number of latest records scanned through the configured record store.
145    pub latest_records: u64,
146    /// Number of immutable version records scanned through the configured record store.
147    pub version_records: u64,
148    /// Number of chunk references inspected across all records.
149    pub inspected_chunk_references: u64,
150    /// Number of dedupe-shard mappings inspected through the index adapter.
151    pub inspected_dedupe_shard_mappings: u64,
152    /// Number of durable reconstruction rows inspected through the index adapter.
153    pub inspected_reconstructions: u64,
154    /// Number of processed provider webhook deliveries inspected through the index adapter.
155    pub inspected_webhook_deliveries: u64,
156    /// Number of provider repository lifecycle states inspected through the index adapter.
157    pub inspected_provider_repository_states: u64,
158    /// Collected integrity issues.
159    pub issues: Vec<FsckIssue>,
160}
161
162impl FsckReport {
163    /// Returns the total issue count.
164    #[must_use]
165    pub const fn issue_count(&self) -> usize {
166        self.issues.len()
167    }
168
169    /// Returns whether the storage root passed every check.
170    #[must_use]
171    pub const fn is_clean(&self) -> bool {
172        self.issues.is_empty()
173    }
174}
175
176/// One integrity issue reported by the checker.
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct FsckIssue {
179    /// Problem classification.
180    pub kind: FsckIssueKind,
181    /// Stable object or record location associated with the issue.
182    pub location: String,
183    /// Structured detail for operators.
184    pub detail: FsckIssueDetail,
185}
186
187/// Integrity issue detail.
188#[derive(Debug, Clone, Error, PartialEq, Eq)]
189pub enum FsckIssueDetail {
190    /// A required version record was missing.
191    #[error("missing version record {version_locator}")]
192    MissingVersionRecord {
193        /// Expected version-record locator.
194        version_locator: String,
195    },
196    /// Record metadata exceeded parser limits.
197    #[error("record metadata exceeded the bounded parser ceiling")]
198    OversizedRecordMetadata,
199    /// Record JSON was invalid.
200    #[error("record json was invalid")]
201    RecordJsonInvalid,
202    /// A record file identifier was invalid.
203    #[error("record file_id `{file_id}` is invalid")]
204    InvalidFileId {
205        /// Invalid file identifier.
206        file_id: String,
207    },
208    /// A record content hash was invalid.
209    #[error("record content hash `{content_hash}` is invalid")]
210    InvalidContentHash {
211        /// Invalid content hash.
212        content_hash: String,
213    },
214    /// A record was stored at an unexpected location.
215    #[error("expected record at {expected_locator}")]
216    RecordPathMismatch {
217        /// Expected record locator.
218        expected_locator: String,
219    },
220    /// A record file identifier did not match its path.
221    #[error("record file_id does not match path")]
222    RecordFileIdPathMismatch,
223    /// A record content hash did not match its path.
224    #[error("record content hash does not match path")]
225    RecordContentHashPathMismatch,
226    /// A reconstruction plan was invalid.
227    #[error("{0}")]
228    InvalidReconstructionPlan(FsckReconstructionPlanDetail),
229    /// A chunk hash was invalid.
230    #[error("chunk hash `{chunk_hash}` is invalid")]
231    InvalidChunkHash {
232        /// Invalid chunk hash.
233        chunk_hash: String,
234    },
235    /// An xorb hash was invalid.
236    #[error("xorb hash `{xorb_hash}` is invalid")]
237    InvalidXorbHash {
238        /// Invalid xorb hash.
239        xorb_hash: String,
240    },
241    /// An object was referenced by a record.
242    #[error("referenced by record {record_location}")]
243    ReferencedByRecord {
244        /// Referencing record location.
245        record_location: String,
246    },
247    /// An object was referenced by a native Xet record.
248    #[error("referenced by native xet record {record_location}")]
249    ReferencedByNativeXetRecord {
250        /// Referencing record location.
251        record_location: String,
252    },
253    /// An object was referenced by a native Xet xorb.
254    #[error("referenced by native xet xorb {xorb_location}")]
255    ReferencedByNativeXetXorb {
256        /// Referencing xorb location.
257        xorb_location: String,
258    },
259    /// A hash comparison failed.
260    #[error("expected {expected_hash}, got {observed_hash}")]
261    HashMismatch {
262        /// Expected hash.
263        expected_hash: String,
264        /// Observed hash.
265        observed_hash: String,
266    },
267    /// A length comparison failed.
268    #[error("expected {expected_length}, got {observed_length}")]
269    LengthMismatch {
270        /// Expected length.
271        expected_length: u64,
272        /// Observed length.
273        observed_length: u64,
274    },
275    /// A native Xet range exceeded the xorb chunk count.
276    #[error("xorb range {range_start}..{range_end} exceeded {chunk_count} chunks")]
277    XorbRangeExceededChunkCount {
278        /// Requested range start.
279        range_start: u32,
280        /// Requested range end.
281        range_end: u32,
282        /// Available chunk count.
283        chunk_count: usize,
284    },
285    /// A latest record differed from its immutable version.
286    #[error("latest record differed from version record {version_locator}")]
287    MismatchedVersionRecord {
288        /// Version-record locator.
289        version_locator: String,
290    },
291    /// A mapped chunk hash points at a retained shard object.
292    #[error("mapped chunk hash {chunk_hash}")]
293    MappedChunkHash {
294        /// Mapped chunk hash.
295        chunk_hash: String,
296    },
297    /// A mapped chunk hash was absent from its retained shard.
298    #[error("mapped chunk hash {chunk_hash} was absent from retained shard")]
299    MappedChunkHashAbsentFromRetainedShard {
300        /// Mapped chunk hash.
301        chunk_hash: String,
302    },
303    /// A retained shard was invalid.
304    #[error("{0}")]
305    InvalidRetainedShard(InvalidSerializedShardError),
306    /// A reconstruction row was listed but unreadable.
307    #[error("reconstruction index listed a file id without a readable row")]
308    ReconstructionListedUnreadableRow,
309    /// A reconstruction contained no terms.
310    #[error("reconstruction contained no terms")]
311    ReconstructionContainedNoTerms,
312    /// A reconstruction referenced an unregistered xorb.
313    #[error("reconstruction referenced unregistered xorb {xorb_hash}")]
314    MissingReconstructionXorb {
315        /// Missing xorb hash.
316        xorb_hash: String,
317    },
318    /// Quarantine delete-after preceded first-seen.
319    #[error(
320        "delete-after {delete_after_unix_seconds} preceded first-seen {first_seen_unreachable_at_unix_seconds}"
321    )]
322    InvalidQuarantineTimeline {
323        /// Candidate delete-after timestamp.
324        delete_after_unix_seconds: u64,
325        /// Candidate first-seen timestamp.
326        first_seen_unreachable_at_unix_seconds: u64,
327    },
328    /// Quarantine metadata referenced a missing object.
329    #[error("quarantine metadata referenced a missing object")]
330    QuarantineReferencedMissingObject,
331    /// Quarantine metadata targeted a reachable object.
332    #[error("quarantine metadata still targeted a reachable live object")]
333    QuarantineTargetedReachableObject,
334    /// Retention hold release-after preceded held-at.
335    #[error("release-after {release_after_unix_seconds} preceded held-at {held_at_unix_seconds}")]
336    InvalidRetentionTimeline {
337        /// Hold release timestamp.
338        release_after_unix_seconds: u64,
339        /// Hold creation timestamp.
340        held_at_unix_seconds: u64,
341    },
342    /// Active retention hold reason for a missing held object.
343    #[error("active retention hold reason: {reason}")]
344    ActiveRetentionHoldReason {
345        /// Retention reason.
346        reason: String,
347    },
348    /// Active retention hold coexisted with quarantine state.
349    #[error("active retention hold still coexisted with quarantine state")]
350    ActiveRetentionHoldQuarantined,
351    /// Webhook delivery timestamp exceeded the accepted future skew.
352    #[error(
353        "processed-at {processed_at_unix_seconds} exceeded max allowed {max_allowed_unix_seconds}"
354    )]
355    WebhookDeliveryTimestampExceeded {
356        /// Observed processed-at timestamp.
357        processed_at_unix_seconds: u64,
358        /// Maximum accepted timestamp.
359        max_allowed_unix_seconds: u64,
360    },
361    /// Provider repository identity failed validation.
362    #[error("provider repository state identity failed repository-scope validation")]
363    ProviderRepositoryIdentityInvalid,
364    /// Provider repository state timestamp exceeded the accepted future skew.
365    #[error("{field} {timestamp} exceeded max allowed {max_allowed_unix_seconds}")]
366    ProviderRepositoryStateTimestampExceeded {
367        /// Timestamp field.
368        field: ProviderRepositoryStateTimestampField,
369        /// Observed timestamp.
370        timestamp: u64,
371        /// Maximum accepted timestamp.
372        max_allowed_unix_seconds: u64,
373    },
374}
375
376/// Reconstruction-plan detail for fsck issues.
377#[derive(Debug, Clone, Copy, Error, PartialEq, Eq)]
378pub enum FsckReconstructionPlanDetail {
379    /// A chunk hash was invalid.
380    #[error("record chunk hash is invalid")]
381    ChunkHashInvalid,
382    /// A chunk was empty.
383    #[error("record contains an empty chunk")]
384    EmptyChunk,
385    /// Chunks were not contiguous.
386    #[error("record chunks are not contiguous")]
387    NonContiguousChunkOffsets,
388    /// A chunk range was invalid.
389    #[error("record chunk range is invalid")]
390    InvalidChunkRange,
391    /// A packed range was invalid.
392    #[error("record packed range is invalid")]
393    InvalidPackedRange,
394    /// Record length overflowed.
395    #[error("record length overflowed")]
396    LengthOverflow,
397    /// Total byte count did not match chunks.
398    #[error("record total byte count did not match chunks")]
399    TotalBytesMismatch,
400}
401
402/// Provider repository state timestamp field.
403#[derive(Debug, Clone, Copy, PartialEq, Eq)]
404pub enum ProviderRepositoryStateTimestampField {
405    /// `last_access_changed_at_unix_seconds`.
406    LastAccessChangedAtUnixSeconds,
407    /// `last_revision_pushed_at_unix_seconds`.
408    LastRevisionPushedAtUnixSeconds,
409    /// `last_cache_invalidated_at_unix_seconds`.
410    LastCacheInvalidatedAtUnixSeconds,
411    /// `last_authorization_rechecked_at_unix_seconds`.
412    LastAuthorizationRecheckedAtUnixSeconds,
413    /// `last_drift_checked_at_unix_seconds`.
414    LastDriftCheckedAtUnixSeconds,
415}
416
417impl ProviderRepositoryStateTimestampField {
418    const fn as_str(self) -> &'static str {
419        match self {
420            Self::LastAccessChangedAtUnixSeconds => "last_access_changed_at_unix_seconds",
421            Self::LastRevisionPushedAtUnixSeconds => "last_revision_pushed_at_unix_seconds",
422            Self::LastCacheInvalidatedAtUnixSeconds => "last_cache_invalidated_at_unix_seconds",
423            Self::LastAuthorizationRecheckedAtUnixSeconds => {
424                "last_authorization_rechecked_at_unix_seconds"
425            }
426            Self::LastDriftCheckedAtUnixSeconds => "last_drift_checked_at_unix_seconds",
427        }
428    }
429}
430
431impl Display for ProviderRepositoryStateTimestampField {
432    fn fmt(&self, formatter: &mut Formatter<'_>) -> FmtResult {
433        formatter.write_str(self.as_str())
434    }
435}
436
437/// Integrity issue kinds for the checker.
438#[derive(Debug, Clone, Copy, PartialEq, Eq)]
439pub enum FsckIssueKind {
440    /// Record metadata exceeded the bounded parser ceiling.
441    OversizedRecordMetadata,
442    /// Record bytes were not valid JSON.
443    InvalidRecordJson,
444    /// Record file identifier failed validation.
445    InvalidFileId,
446    /// Record content hash failed validation.
447    InvalidContentHash,
448    /// Record was stored at an unexpected path.
449    RecordPathMismatch,
450    /// Record chunk offsets were not contiguous.
451    NonContiguousChunks,
452    /// Record contained an empty chunk term.
453    EmptyChunk,
454    /// Record total byte count did not match chunk lengths.
455    TotalBytesMismatch,
456    /// Record contained an empty or inverted xorb chunk range.
457    InvalidChunkRange,
458    /// Record contained an empty or inverted packed xorb byte range.
459    InvalidPackedRange,
460    /// Record content hash did not match reconstructed metadata hash.
461    RecordHashMismatch,
462    /// Referenced chunk bytes were missing.
463    MissingChunk,
464    /// Referenced chunk body did not hash to the declared chunk hash.
465    ChunkHashMismatch,
466    /// Referenced chunk byte length did not match the record.
467    ChunkLengthMismatch,
468    /// Visible latest record did not have a matching immutable version record.
469    MissingVersionRecord,
470    /// Visible latest record differed from its immutable version record.
471    MismatchedVersionRecord,
472    /// Indexed retained-shard object was missing from object storage.
473    MissingDedupeShardObject,
474    /// Indexed retained-shard object could not be parsed as a native Xet shard.
475    InvalidRetainedShard,
476    /// Indexed retained-shard object did not contain the mapped chunk hash.
477    InvalidDedupeShardMapping,
478    /// Durable reconstruction metadata did not contain any terms.
479    EmptyReconstruction,
480    /// Durable reconstruction metadata referenced an unregistered xorb.
481    MissingReconstructionXorb,
482    /// Quarantine metadata had an invalid retention timeline.
483    InvalidQuarantineCandidate,
484    /// Quarantine metadata referenced an object that no longer existed.
485    MissingQuarantinedObject,
486    /// Quarantine metadata length disagreed with current object metadata.
487    QuarantineLengthMismatch,
488    /// Quarantine metadata still targeted a reachable live object.
489    ReachableQuarantinedObject,
490    /// Retention-hold metadata had an invalid timeline.
491    InvalidRetentionHold,
492    /// An active retention hold referenced an object that no longer existed.
493    MissingHeldObject,
494    /// An active retention hold still coexisted with quarantine state for the same object.
495    HeldQuarantinedObject,
496    /// A processed webhook delivery had a timestamp too far in the future.
497    InvalidWebhookDeliveryTimestamp,
498    /// Provider repository lifecycle metadata had an invalid repository identity.
499    InvalidProviderRepositoryState,
500    /// Provider repository lifecycle metadata had a timestamp too far in the future.
501    InvalidProviderRepositoryStateTimestamp,
502}
503
504impl FsckIssueKind {
505    /// Stable issue label for CLI and logs.
506    #[must_use]
507    pub const fn as_str(self) -> &'static str {
508        match self {
509            Self::OversizedRecordMetadata => "oversized_record_metadata",
510            Self::InvalidRecordJson => "invalid_record_json",
511            Self::InvalidFileId => "invalid_file_id",
512            Self::InvalidContentHash => "invalid_content_hash",
513            Self::RecordPathMismatch => "record_path_mismatch",
514            Self::NonContiguousChunks => "non_contiguous_chunks",
515            Self::EmptyChunk => "empty_chunk",
516            Self::TotalBytesMismatch => "total_bytes_mismatch",
517            Self::InvalidChunkRange => "invalid_chunk_range",
518            Self::InvalidPackedRange => "invalid_packed_range",
519            Self::RecordHashMismatch => "record_hash_mismatch",
520            Self::MissingChunk => "missing_chunk",
521            Self::ChunkHashMismatch => "chunk_hash_mismatch",
522            Self::ChunkLengthMismatch => "chunk_length_mismatch",
523            Self::MissingVersionRecord => "missing_version_record",
524            Self::MismatchedVersionRecord => "mismatched_version_record",
525            Self::MissingDedupeShardObject => "missing_dedupe_shard_object",
526            Self::InvalidRetainedShard => "invalid_retained_shard",
527            Self::InvalidDedupeShardMapping => "invalid_dedupe_shard_mapping",
528            Self::EmptyReconstruction => "empty_reconstruction",
529            Self::MissingReconstructionXorb => "missing_reconstruction_xorb",
530            Self::InvalidQuarantineCandidate => "invalid_quarantine_candidate",
531            Self::MissingQuarantinedObject => "missing_quarantined_object",
532            Self::QuarantineLengthMismatch => "quarantine_length_mismatch",
533            Self::ReachableQuarantinedObject => "reachable_quarantined_object",
534            Self::InvalidRetentionHold => "invalid_retention_hold",
535            Self::MissingHeldObject => "missing_held_object",
536            Self::HeldQuarantinedObject => "held_quarantined_object",
537            Self::InvalidWebhookDeliveryTimestamp => "invalid_webhook_delivery_timestamp",
538            Self::InvalidProviderRepositoryState => "invalid_provider_repository_state",
539            Self::InvalidProviderRepositoryStateTimestamp => {
540                "invalid_provider_repository_state_timestamp"
541            }
542        }
543    }
544}
545
546/// Backward-compatible local fsck report alias.
547pub type LocalFsckReport = FsckReport;
548
549/// Backward-compatible local fsck issue alias.
550pub type LocalFsckIssue = FsckIssue;
551
552/// Backward-compatible local fsck issue-kind alias.
553pub type LocalFsckIssueKind = FsckIssueKind;
554
555pub const WEBHOOK_DELIVERY_FUTURE_SKEW_SECONDS: u64 = 300;
556
557/// Runs local filesystem integrity checks over Shardline metadata and chunk storage.
558///
559/// # Errors
560///
561/// Returns [`FsckError`] when the storage root cannot be traversed or chunk/record
562/// bytes cannot be read due to an operational failure.
563pub async fn run_local_fsck(root: PathBuf) -> Result<LocalFsckReport, FsckError> {
564    let object_root = root.join("chunks");
565    let object_store = ServerObjectStore::local(object_root.clone())?;
566    let index_store = shardline_index::LocalIndexStore::open(root.clone());
567    let record_store = shardline_index::LocalRecordStore::open(root);
568    run_fsck_with_stores(
569        &record_store,
570        &index_store,
571        &object_root,
572        &object_store,
573        shardline_server_core::DEFAULT_SHARD_METADATA_LIMITS,
574    )
575    .await
576}
577
578/// Runs local filesystem integrity checks over Shardline metadata and chunk storage
579/// using explicit store parameters.
580///
581/// # Errors
582///
583/// Returns [`FsckError`] when the storage root cannot be traversed or chunk/record
584/// bytes cannot be read due to an operational failure.
585pub async fn run_fsck_with_stores<RecordAdapter, IndexAdapter>(
586    record_store: &RecordAdapter,
587    index_store: &IndexAdapter,
588    object_root: &Path,
589    object_store: &ServerObjectStore,
590    shard_metadata_limits: ShardMetadataLimits,
591) -> Result<FsckReport, FsckError>
592where
593    RecordAdapter: OpsRecordStore + Sync,
594    RecordAdapter::Error: Into<FsckError>,
595    IndexAdapter: AsyncIndexStore + Sync,
596    IndexAdapter::Error: Into<FsckError>,
597{
598    let start = std::time::Instant::now();
599    let mut report = FsckReport {
600        latest_records: 0,
601        version_records: 0,
602        inspected_chunk_references: 0,
603        inspected_dedupe_shard_mappings: 0,
604        inspected_reconstructions: 0,
605        inspected_webhook_deliveries: 0,
606        inspected_provider_repository_states: 0,
607        issues: Vec::new(),
608    };
609    let mut reachability = FsckReachability::default();
610
611    scan_record_tree(
612        record_store,
613        RecordKind::Latest,
614        object_root,
615        object_store,
616        &mut reachability,
617        &mut report,
618    )
619    .await?;
620    scan_record_tree(
621        record_store,
622        RecordKind::Version,
623        object_root,
624        object_store,
625        &mut reachability,
626        &mut report,
627    )
628    .await?;
629    inspect_dedupe_shard_mappings(
630        index_store,
631        object_root,
632        object_store,
633        shard_metadata_limits,
634        &mut reachability,
635        &mut report,
636    )
637    .await?;
638    inspect_reconstruction_index(index_store, &mut report).await?;
639    inspect_lifecycle_metadata(
640        index_store,
641        object_root,
642        object_store,
643        &reachability,
644        &mut report,
645    )
646    .await?;
647
648    let elapsed = start.elapsed();
649    shardline_metrics::record_fsck_run(elapsed, report.issue_count() as u64);
650
651    Ok(report)
652}
653
654#[cfg(test)]
655mod tests {
656    use super::*;
657
658    fn clean_report() -> FsckReport {
659        FsckReport {
660            latest_records: 0,
661            version_records: 0,
662            inspected_chunk_references: 0,
663            inspected_dedupe_shard_mappings: 0,
664            inspected_reconstructions: 0,
665            inspected_webhook_deliveries: 0,
666            inspected_provider_repository_states: 0,
667            issues: Vec::new(),
668        }
669    }
670
671    #[test]
672    fn is_clean_returns_true_for_empty_issues() {
673        assert!(clean_report().is_clean());
674    }
675
676    #[test]
677    fn is_clean_returns_false_when_issues_present() {
678        let mut report = clean_report();
679        report.issues.push(FsckIssue {
680            kind: FsckIssueKind::MissingChunk,
681            location: "test".to_owned(),
682            detail: FsckIssueDetail::HashMismatch {
683                expected_hash: "a".repeat(64),
684                observed_hash: "b".repeat(64),
685            },
686        });
687        assert!(!report.is_clean());
688    }
689
690    #[test]
691    fn issue_count_zero_for_clean_report() {
692        assert_eq!(clean_report().issue_count(), 0);
693    }
694
695    #[test]
696    fn issue_count_matches_issues_length() {
697        let mut report = clean_report();
698        report.issues.push(FsckIssue {
699            kind: FsckIssueKind::MissingChunk,
700            location: "a".to_owned(),
701            detail: FsckIssueDetail::HashMismatch {
702                expected_hash: "a".repeat(64),
703                observed_hash: "b".repeat(64),
704            },
705        });
706        report.issues.push(FsckIssue {
707            kind: FsckIssueKind::ChunkHashMismatch,
708            location: "b".to_owned(),
709            detail: FsckIssueDetail::InvalidChunkHash {
710                chunk_hash: "c".repeat(64),
711            },
712        });
713        assert_eq!(report.issue_count(), 2);
714    }
715}
716
717#[derive(Debug, Clone, Copy, PartialEq, Eq)]
718pub(crate) enum RecordKind {
719    Latest,
720    Version,
721}
722
723pub(crate) struct FsckObjectContext<'operation> {
724    pub(crate) object_root: &'operation Path,
725    pub(crate) object_store: &'operation ServerObjectStore,
726}
727
728#[derive(Debug, Default)]
729pub(crate) struct FsckReachability {
730    pub(crate) referenced_object_keys: HashSet<String>,
731    pub(crate) live_dedupe_chunk_hashes: HashSet<String>,
732}
733
734pub(crate) struct PendingVersionRecordCheck<Locator> {
735    pub(crate) latest_locator: Locator,
736    pub(crate) version_locator: Locator,
737    pub(crate) latest_record: FileRecord,
738}
739
740impl RecordKind {
741    pub(crate) const fn ops(self) -> OpsRecordKind {
742        match self {
743            Self::Latest => OpsRecordKind::Latest,
744            Self::Version => OpsRecordKind::Version,
745        }
746    }
747}
748
749pub(crate) fn push_reconstruction_plan_issue(
750    report: &mut FsckReport,
751    location: String,
752    error: &FileRecordInvariantError,
753) -> Result<(), FsckError> {
754    let kind = match error {
755        FileRecordInvariantError::ChunkHash(_) => FsckIssueKind::InvalidContentHash,
756        FileRecordInvariantError::EmptyChunk => FsckIssueKind::EmptyChunk,
757        FileRecordInvariantError::NonContiguousChunkOffsets => FsckIssueKind::NonContiguousChunks,
758        FileRecordInvariantError::InvalidChunkRange => FsckIssueKind::InvalidChunkRange,
759        FileRecordInvariantError::InvalidPackedRange => FsckIssueKind::InvalidPackedRange,
760        FileRecordInvariantError::LengthOverflow | FileRecordInvariantError::TotalBytesMismatch => {
761            FsckIssueKind::TotalBytesMismatch
762        }
763    };
764    push_issue(
765        report,
766        kind,
767        location,
768        FsckIssueDetail::InvalidReconstructionPlan(reconstruction_plan_error_detail(error)),
769    )
770}
771
772pub(crate) fn push_issue(
773    report: &mut FsckReport,
774    kind: FsckIssueKind,
775    location: String,
776    detail: FsckIssueDetail,
777) -> Result<(), FsckError> {
778    let _count = u64::try_from(report.issues.len())?;
779    report.issues.push(FsckIssue {
780        kind,
781        location,
782        detail,
783    });
784    Ok(())
785}
786
787const fn reconstruction_plan_error_detail(
788    error: &FileRecordInvariantError,
789) -> FsckReconstructionPlanDetail {
790    match error {
791        FileRecordInvariantError::ChunkHash(_) => FsckReconstructionPlanDetail::ChunkHashInvalid,
792        FileRecordInvariantError::EmptyChunk => FsckReconstructionPlanDetail::EmptyChunk,
793        FileRecordInvariantError::NonContiguousChunkOffsets => {
794            FsckReconstructionPlanDetail::NonContiguousChunkOffsets
795        }
796        FileRecordInvariantError::InvalidChunkRange => {
797            FsckReconstructionPlanDetail::InvalidChunkRange
798        }
799        FileRecordInvariantError::InvalidPackedRange => {
800            FsckReconstructionPlanDetail::InvalidPackedRange
801        }
802        FileRecordInvariantError::LengthOverflow => FsckReconstructionPlanDetail::LengthOverflow,
803        FileRecordInvariantError::TotalBytesMismatch => {
804            FsckReconstructionPlanDetail::TotalBytesMismatch
805        }
806    }
807}
808
809pub(crate) fn record_path<RecordAdapter>(
810    record_store: &RecordAdapter,
811    record_kind: RecordKind,
812    record: &FileRecord,
813) -> RecordAdapter::Locator
814where
815    RecordAdapter: OpsRecordStore,
816{
817    match record_kind {
818        RecordKind::Latest => record_store.latest_record_locator(record),
819        RecordKind::Version => record_store.version_record_locator(record),
820    }
821}
822
823fn object_key_storage_path(object_root: &Path, object_key: &ObjectKey) -> PathBuf {
824    object_root.join(object_key.as_str())
825}
826
827pub(crate) fn object_location_display(
828    object_root: &Path,
829    object_store: &ServerObjectStore,
830    object_key: &ObjectKey,
831) -> String {
832    object_store
833        .local_path_for_key(object_key)
834        .unwrap_or_else(|| object_key_storage_path(object_root, object_key))
835        .display()
836        .to_string()
837}
838
839pub(crate) fn unix_now_seconds_checked() -> Result<u64, FsckError> {
840    shardline_server_core::unix_now_seconds_checked().map_err(|_e| FsckError::Overflow)
841}
842
843async fn inspect_dedupe_shard_mappings<IndexAdapter>(
844    index_store: &IndexAdapter,
845    object_root: &Path,
846    object_store: &ServerObjectStore,
847    shard_metadata_limits: ShardMetadataLimits,
848    reachability: &mut FsckReachability,
849    report: &mut FsckReport,
850) -> Result<(), FsckError>
851where
852    IndexAdapter: AsyncIndexStore + Sync,
853    IndexAdapter::Error: Into<FsckError>,
854{
855    index_store
856        .visit_dedupe_shard_mappings(|mapping| {
857            report.inspected_dedupe_shard_mappings =
858                checked_increment(report.inspected_dedupe_shard_mappings)?;
859            let chunk_hash_hex = xet_hash_hex_string(mapping.chunk_hash());
860            let shard_location =
861                object_location_display(object_root, object_store, mapping.shard_object_key());
862            let metadata = match object_store.metadata(mapping.shard_object_key())? {
863                Some(metadata) => metadata,
864                None => {
865                    push_issue(
866                        report,
867                        FsckIssueKind::MissingDedupeShardObject,
868                        shard_location,
869                        FsckIssueDetail::MappedChunkHash {
870                            chunk_hash: chunk_hash_hex,
871                        },
872                    )?;
873                    return Ok::<(), FsckError>(());
874                }
875            };
876            let shard_bytes =
877                read_full_object(object_store, mapping.shard_object_key(), metadata.length())?;
878            let chunk_hashes =
879                match retained_shard_chunk_hashes(&shard_bytes, shard_metadata_limits) {
880                    Ok(chunk_hashes) => chunk_hashes,
881                    Err(XetAdapterError::InvalidSerializedShard(detail)) => {
882                        push_issue(
883                            report,
884                            FsckIssueKind::InvalidRetainedShard,
885                            shard_location,
886                            FsckIssueDetail::InvalidRetainedShard(detail),
887                        )?;
888                        return Ok::<(), FsckError>(());
889                    }
890                    Err(error) => return Err(error.into()),
891                };
892            if !chunk_hashes
893                .iter()
894                .any(|candidate| candidate == &chunk_hash_hex)
895            {
896                push_issue(
897                    report,
898                    FsckIssueKind::InvalidDedupeShardMapping,
899                    shard_location,
900                    FsckIssueDetail::MappedChunkHashAbsentFromRetainedShard {
901                        chunk_hash: chunk_hash_hex.clone(),
902                    },
903                )?;
904            }
905            if reachability
906                .live_dedupe_chunk_hashes
907                .contains(&chunk_hash_hex)
908            {
909                reachability
910                    .referenced_object_keys
911                    .insert(mapping.shard_object_key().as_str().to_owned());
912            }
913            Ok::<(), FsckError>(())
914        })
915        .await?;
916
917    Ok(())
918}
919
920async fn inspect_reconstruction_index<IndexAdapter>(
921    index_store: &IndexAdapter,
922    report: &mut FsckReport,
923) -> Result<(), FsckError>
924where
925    IndexAdapter: AsyncIndexStore + Sync,
926    IndexAdapter::Error: Into<FsckError>,
927{
928    let file_ids = index_store
929        .list_reconstruction_file_ids()
930        .await
931        .map_err(Into::into)?;
932    for file_id in file_ids {
933        report.inspected_reconstructions = checked_increment(report.inspected_reconstructions)?;
934        let file_id_hex = xet_hash_hex_string(file_id.hash());
935        let Some(reconstruction) = index_store
936            .reconstruction(&file_id)
937            .await
938            .map_err(Into::into)?
939        else {
940            push_issue(
941                report,
942                FsckIssueKind::EmptyReconstruction,
943                file_id_hex,
944                FsckIssueDetail::ReconstructionListedUnreadableRow,
945            )?;
946            continue;
947        };
948
949        if reconstruction.terms().is_empty() {
950            push_issue(
951                report,
952                FsckIssueKind::EmptyReconstruction,
953                file_id_hex.clone(),
954                FsckIssueDetail::ReconstructionContainedNoTerms,
955            )?;
956        }
957
958        for term in reconstruction.terms() {
959            let object_id = term.object_id();
960            if !index_store
961                .contains_object(&object_id)
962                .await
963                .map_err(Into::into)?
964            {
965                push_issue(
966                    report,
967                    FsckIssueKind::MissingReconstructionXorb,
968                    file_id_hex.clone(),
969                    FsckIssueDetail::MissingReconstructionXorb {
970                        xorb_hash: xet_hash_hex_string(object_id.hash()),
971                    },
972                )?;
973            }
974        }
975    }
976
977    Ok(())
978}