|
| 1 | +""" |
| 2 | +Context profile templates for coercion-safe dummy dataset guidance. |
| 3 | +
|
| 4 | +A context profile defines the expected content structure for a given operational |
| 5 | +context. Profiles guide dummy generation and provide plausibility validation. |
| 6 | +
|
| 7 | +Built-in profiles: travel, field_engineer, researcher, maintenance, archive. |
| 8 | +
|
| 9 | +Profiles are purely advisory. They do not forge metadata, fake system events, |
| 10 | +or perform any anti-forensic tampering. |
| 11 | +""" |
| 12 | + |
| 13 | +from __future__ import annotations |
| 14 | + |
| 15 | +from dataclasses import dataclass, field |
| 16 | + |
| 17 | + |
| 18 | +@dataclass(frozen=True) |
| 19 | +class ContextProfile: |
| 20 | + profile_name: str |
| 21 | + container_name: str |
| 22 | + expected_size_range: tuple[int, int] # (min_bytes, max_bytes) |
| 23 | + dummy_content_types: tuple[str, ...] |
| 24 | + description: str |
| 25 | + typical_directories: tuple[str, ...] |
| 26 | + min_file_count: int |
| 27 | + occupancy_ratio_warn: float # warn when ratio is below this |
| 28 | + |
| 29 | + def validate(self) -> list[str]: |
| 30 | + """Return a list of plausibility warnings for this profile configuration.""" |
| 31 | + warnings: list[str] = [] |
| 32 | + min_bytes, max_bytes = self.expected_size_range |
| 33 | + if min_bytes < 0: |
| 34 | + warnings.append("expected_size_range minimum is negative") |
| 35 | + if max_bytes < min_bytes: |
| 36 | + warnings.append("expected_size_range maximum is less than minimum") |
| 37 | + if max_bytes < 1024 * 1024: |
| 38 | + warnings.append("expected_size_range maximum is below 1 MiB — unusually small") |
| 39 | + if self.min_file_count < 1: |
| 40 | + warnings.append("min_file_count is zero — container will appear empty") |
| 41 | + if self.occupancy_ratio_warn < 0.05: |
| 42 | + warnings.append( |
| 43 | + "occupancy_ratio_warn is below 5% — extremely low occupancy may appear suspicious" |
| 44 | + ) |
| 45 | + if not self.dummy_content_types: |
| 46 | + warnings.append("no dummy_content_types defined — container will have no file guidance") |
| 47 | + return warnings |
| 48 | + |
| 49 | + |
| 50 | +BUILT_IN_PROFILES: dict[str, ContextProfile] = { |
| 51 | + "travel": ContextProfile( |
| 52 | + profile_name="travel", |
| 53 | + container_name="travel.vessel", |
| 54 | + expected_size_range=(50 * 1024 * 1024, 2 * 1024 * 1024 * 1024), |
| 55 | + dummy_content_types=("jpg", "jpeg", "png", "txt", "pdf", "html"), |
| 56 | + description=( |
| 57 | + "Travel data carrier. Expected content: photos, itinerary, notes, receipts. " |
| 58 | + "Consistent with a traveler carrying trip documentation." |
| 59 | + ), |
| 60 | + typical_directories=("photos", "itinerary", "notes", "receipts", "maps"), |
| 61 | + min_file_count=20, |
| 62 | + occupancy_ratio_warn=0.10, |
| 63 | + ), |
| 64 | + "field_engineer": ContextProfile( |
| 65 | + profile_name="field_engineer", |
| 66 | + container_name="field.vessel", |
| 67 | + expected_size_range=(20 * 1024 * 1024, 512 * 1024 * 1024), |
| 68 | + dummy_content_types=("log", "txt", "json", "yaml", "csv", "pdf"), |
| 69 | + description=( |
| 70 | + "Engineering field work carrier. Expected content: logs, configs, exported " |
| 71 | + "diagnostics, manuals. Consistent with a field engineer carrying operational data." |
| 72 | + ), |
| 73 | + typical_directories=("logs", "configs", "diagnostics", "manuals", "exports"), |
| 74 | + min_file_count=15, |
| 75 | + occupancy_ratio_warn=0.08, |
| 76 | + ), |
| 77 | + "researcher": ContextProfile( |
| 78 | + profile_name="researcher", |
| 79 | + container_name="research.vessel", |
| 80 | + expected_size_range=(50 * 1024 * 1024, 4 * 1024 * 1024 * 1024), |
| 81 | + dummy_content_types=("pdf", "txt", "csv", "json", "md", "bib"), |
| 82 | + description=( |
| 83 | + "Research material carrier. Expected content: PDFs, notes, references, exported " |
| 84 | + "datasets. Consistent with a researcher carrying working documents." |
| 85 | + ), |
| 86 | + typical_directories=("papers", "notes", "references", "datasets", "drafts"), |
| 87 | + min_file_count=25, |
| 88 | + occupancy_ratio_warn=0.12, |
| 89 | + ), |
| 90 | + "maintenance": ContextProfile( |
| 91 | + profile_name="maintenance", |
| 92 | + container_name="maintenance.vessel", |
| 93 | + expected_size_range=(10 * 1024 * 1024, 256 * 1024 * 1024), |
| 94 | + dummy_content_types=("log", "txt", "json", "csv", "xml"), |
| 95 | + description=( |
| 96 | + "Device maintenance carrier. Expected content: diagnostic exports, system check " |
| 97 | + "results, update files. Consistent with a maintenance technician." |
| 98 | + ), |
| 99 | + typical_directories=("diagnostics", "checks", "updates", "reports", "backups"), |
| 100 | + min_file_count=10, |
| 101 | + occupancy_ratio_warn=0.06, |
| 102 | + ), |
| 103 | + "archive": ContextProfile( |
| 104 | + profile_name="archive", |
| 105 | + container_name="archive.vessel", |
| 106 | + expected_size_range=(100 * 1024 * 1024, 8 * 1024 * 1024 * 1024), |
| 107 | + dummy_content_types=("pdf", "txt", "jpg", "png", "docx", "xlsx", "csv"), |
| 108 | + description=( |
| 109 | + "Long-term archive carrier. Expected content: documents, media, backups. " |
| 110 | + "Consistent with a general-purpose personal or work archive." |
| 111 | + ), |
| 112 | + typical_directories=("documents", "media", "backups", "exports", "misc"), |
| 113 | + min_file_count=30, |
| 114 | + occupancy_ratio_warn=0.15, |
| 115 | + ), |
| 116 | +} |
| 117 | + |
| 118 | + |
| 119 | +def get_profile(name: str) -> ContextProfile | None: |
| 120 | + """Return a built-in profile by name, or None if not found.""" |
| 121 | + return BUILT_IN_PROFILES.get(name.strip().lower()) |
| 122 | + |
| 123 | + |
| 124 | +def list_profiles() -> list[str]: |
| 125 | + """Return sorted list of built-in profile names.""" |
| 126 | + return sorted(BUILT_IN_PROFILES.keys()) |
| 127 | + |
| 128 | + |
| 129 | +@dataclass |
| 130 | +class ProfileValidationResult: |
| 131 | + profile_name: str |
| 132 | + container_size_bytes: int |
| 133 | + dummy_size_bytes: int |
| 134 | + file_count: int |
| 135 | + occupancy_ratio: float |
| 136 | + extension_distribution: dict[str, int] |
| 137 | + warnings: list[str] = field(default_factory=list) |
| 138 | + |
| 139 | + @property |
| 140 | + def is_plausible(self) -> bool: |
| 141 | + return len(self.warnings) == 0 |
| 142 | + |
| 143 | + |
| 144 | +def validate_against_profile( |
| 145 | + *, |
| 146 | + profile: ContextProfile, |
| 147 | + container_size_bytes: int, |
| 148 | + dummy_size_bytes: int, |
| 149 | + file_count: int, |
| 150 | + extension_distribution: dict[str, int], |
| 151 | +) -> ProfileValidationResult: |
| 152 | + """ |
| 153 | + Validate a dummy dataset against a context profile. |
| 154 | +
|
| 155 | + Returns a ProfileValidationResult with any plausibility warnings. |
| 156 | + This is advisory only — it does not modify any files. |
| 157 | + """ |
| 158 | + warnings: list[str] = [] |
| 159 | + |
| 160 | + min_bytes, max_bytes = profile.expected_size_range |
| 161 | + if container_size_bytes > 0: |
| 162 | + if container_size_bytes < min_bytes: |
| 163 | + warnings.append( |
| 164 | + f"container size {_human_bytes(container_size_bytes)} is below " |
| 165 | + f"expected minimum {_human_bytes(min_bytes)} for '{profile.profile_name}'" |
| 166 | + ) |
| 167 | + if container_size_bytes > max_bytes: |
| 168 | + warnings.append( |
| 169 | + f"container size {_human_bytes(container_size_bytes)} exceeds " |
| 170 | + f"expected maximum {_human_bytes(max_bytes)} for '{profile.profile_name}'" |
| 171 | + ) |
| 172 | + |
| 173 | + occupancy_ratio = 0.0 |
| 174 | + if container_size_bytes > 0: |
| 175 | + occupancy_ratio = dummy_size_bytes / float(container_size_bytes) |
| 176 | + if container_size_bytes > 0 and occupancy_ratio < profile.occupancy_ratio_warn: |
| 177 | + warnings.append( |
| 178 | + f"occupancy ratio {occupancy_ratio:.1%} is below " |
| 179 | + f"warning threshold {profile.occupancy_ratio_warn:.1%} for '{profile.profile_name}'" |
| 180 | + ) |
| 181 | + |
| 182 | + if file_count < profile.min_file_count: |
| 183 | + warnings.append( |
| 184 | + f"file count {file_count} is below " |
| 185 | + f"minimum {profile.min_file_count} for '{profile.profile_name}'" |
| 186 | + ) |
| 187 | + |
| 188 | + if extension_distribution: |
| 189 | + expected = set(profile.dummy_content_types) |
| 190 | + actual = {ext.lower().lstrip(".") for ext in extension_distribution} |
| 191 | + unexpected = actual - expected |
| 192 | + if unexpected and not (actual & expected): |
| 193 | + warnings.append( |
| 194 | + f"file types {sorted(unexpected)} are not among expected types " |
| 195 | + f"{sorted(expected)} for '{profile.profile_name}'" |
| 196 | + ) |
| 197 | + |
| 198 | + if dummy_size_bytes == 0: |
| 199 | + warnings.append("dummy dataset is empty — disclosure will appear trivially empty") |
| 200 | + |
| 201 | + return ProfileValidationResult( |
| 202 | + profile_name=profile.profile_name, |
| 203 | + container_size_bytes=container_size_bytes, |
| 204 | + dummy_size_bytes=dummy_size_bytes, |
| 205 | + file_count=file_count, |
| 206 | + occupancy_ratio=occupancy_ratio, |
| 207 | + extension_distribution=dict(extension_distribution), |
| 208 | + warnings=warnings, |
| 209 | + ) |
| 210 | + |
| 211 | + |
| 212 | +def _human_bytes(value: int) -> str: |
| 213 | + if value < 1024: |
| 214 | + return f"{value} B" |
| 215 | + size = float(value) |
| 216 | + for unit in ("KiB", "MiB", "GiB", "TiB"): |
| 217 | + size /= 1024.0 |
| 218 | + if size < 1024.0: |
| 219 | + return f"{size:.1f} {unit}" |
| 220 | + return f"{size:.1f} PiB" |
0 commit comments