> For the complete documentation index, see [llms.txt](https://wong-coupon.gitbook.io/flutter/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://wong-coupon.gitbook.io/flutter/my-flutter/ui-media/secure-documents-attachments-flutter.md).

# Secure Documents and Attachments

How I put pickers, downloads, exports, and media through one policy before previewing, uploading, saving, opening, or sharing

## Result

In the source I reviewed, files enter the app through several paths: the document picker, image picker or camera, network responses, app-generated exports, and media download URLs. The flows already have individual guards such as image-count limits, size checks, extension selection, a PDF viewer, or a permission request before saving. However, they do not yet form a shared trust boundary.

**\[Source verified]** Picker paths or metadata, server-provided MIME, a plausible filename extension, and successful viewer opening are each used by some flows. None of these signals alone proves that the bytes are safe to upload or hand off.

**\[Proposed design]** I put every byte source through the same pipeline:

```
picker / camera / download / generated export
                    │
                    ▼
              UNTRUSTED SOURCE
                    │
                    ▼
 FilePolicy + SourcePolicy + counted stream cap
 declared MIME + magic/container parser + digest
                    │
                    ▼
         SEALED APP-OWNED SNAPSHOT
                    │
                    ▼
           VerifiedFileLease
                    │
        purpose check + TOCTOU revalidation
          ┌─────────┼──────────┐
          ▼         ▼          ▼
       preview    upload    save/open/share
                     │          │
                     ▼          ▼
          backend revalidation  platform-scoped handoff
```

This contract produces the following result:

* A caller can create an `UnverifiedFileSource`, but cannot construct or subclass `VerifiedFileLease`.
* `FilePolicy.validated` rejects invalid configuration in release builds without relying on a debug-build `assert`.
* A remote source has a typed `Origin` containing HTTPS, a normalized host, and an effective port; credentials are decided again for the initial request and every redirect hop.
* `Content-Length` is only a preflight signal. The counted stream is authoritative for quota and updates the digest while writing the `.part` file.
* The extension, declared MIME, magic bytes, and parser or container result are reconciled before the snapshot is sealed.
* The verified lease only lets consumers read through a scoped stream; it exposes no `dart:io File`, path, or writable sink.
* Before every `SensitiveUse`, the lease checks purpose and then revalidates identity, length, digest, and the parser when policy requires it.
* Cancellation and errors go through a single-flight finish. Primary and suppressed cleanup use only allowlisted enums; operational state stores no raw error, message, or path.
* The upload receipt is a separate stage: retrying attach does not upload again, while retrying upload preserves the idempotency key.
* The backend still revalidates content and authorization; external open or share receives only a copy or a narrowly scoped URI grant.

**\[Configured]** The dependency snapshot has Flutter 3.41.2, Dart 3.11 up to but not including 4.0, the corresponding picker, viewer, network, storage, and sharing packages, and Android/iOS configuration. Configuration is not runtime evidence.

**\[Test evidence]** Some current widget tests cover only loading, rendering, or toolbar behavior. The focused test command for file flows could not run because a private dependency did not resolve in the research environment; retrying without fetching dependencies also lacked a compatible cache or artifact and ended with `+0` tests.

**\[Runtime/backend/dashboard unknown]** I have not run the proposed pipeline on a device, observed a real redirect, checked backend scanning or idempotency, or collected a dashboard or performance profile. This article therefore separates the current implementation from the target design and does not call either production evidence.

## Problem

### Paths, extensions, and MIME are all untrusted data

A document picker can return a path or a provider-backed reference. A network response can return `Content-Type`. `Content-Disposition` can supply a filename. These signals help the UX, but they are not content verification.

| Signal                 | Useful for                                 | Must not be treated as                          |
| ---------------------- | ------------------------------------------ | ----------------------------------------------- |
| Picker filter          | Reducing incorrect choices in the UI       | Proof that bytes have the expected type         |
| Filename/extension     | Display hint                               | Local-path or file-kind authority               |
| Declared MIME          | Preflight and diagnostics                  | The sole detected MIME value                    |
| `Content-Length`       | Early rejection when already above the cap | A guarantee that the stream stays under the cap |
| Successful viewer open | UX result                                  | A malware or content-safety verdict             |

**\[Source verified]** The general document flow infers type from the extension before upload; some image flows also check only the suffix and size. PDF bytes are passed directly to a renderer in several places. Generated exports distinguish a JSON error from a file mainly through the response `Content-Type`, but do not yet verify the container or apply a shared counted cap.

Magic bytes are also insufficient when the format is a container. DOCX and XLSX are both ZIP-based; looking only at the `PK` prefix neither distinguishes them nor prevents an archive bomb. A PDF signature does not replace parser policy. A decodable image can still exceed dimension or memory budgets, while video needs its own container parser and quota.

### Buffering all bytes applies the quota too late

**\[Source verified]** Some downloads collect every chunk into a list before creating a byte array; some requests take the entire `bodyBytes`; multi-upload reads several complete files and then runs them in parallel. One media download streams to a path, but still lacks a consistent declared-length guard, counted stream cap, cancellation token, and partial cleanup.

When size is checked after `readAsBytes()`, the app has already paid the RAM and I/O cost. If the server omits `Content-Length`, reports less than the actual body, or uses chunked transfer, that guard no longer works. The cap must sit on the stream path and stop at the chunk that pushes the total beyond policy.

Connectivity must not be used to guess the cause of a file failure either. Wi-Fi can be connected while a request times out, a captive portal returns HTML, or the backend returns a JSON error instead of a file.

{% content-ref url="/pages/kThPOkuxgcDBnbaVwp31" %}
[Flutter Connectivity and Network Failures](/flutter/my-flutter/systems-realtime/connectivity-offline-ux-vpn.md)
{% endcontent-ref %}

### A temporary file without an owner becomes residual data

**\[Source verified]** The source has several helpers that create files in cache, app Documents, or an external destination. Some names are generated from a UUID or timestamp, while some paths concatenate dynamic display text or a filename from message metadata. There is no shared lease, TTL, or startup sweeper across all reviewed flows.

A display name can contain slashes, backslashes, control characters, deceptive Unicode, a reserved name, or an excessively long string. I do not try to “sanitize it well enough and then trust it.” The display name is for UI only; a local artifact uses a random ID and an extension selected by the verifier.

Document and cache storage also differ from secret storage. File bytes, temporary TTL, and handoff ownership should not be mixed with the policy for storing credentials in Keychain or Keystore.

{% content-ref url="/pages/Fbl8YWIUovHWvjb7NUlz" %}
[Secure Storage: Keychain and Keystore](/flutter/my-flutter/security-observability/secure-storage-keychain-keystore.md)
{% endcontent-ref %}

### Cancelling a `Future` does not necessarily stop the transport

**\[Source verified]** One export flow has a cancellable UI operation, but its cancellation handle is not passed down to the HTTP client. Other upload and download helpers also lack consistent connect, idle, and total timeouts.

If the UI merely stops waiting, the network can keep running, the sink can keep writing, and the `.part` file can remain on disk. Conversely, a `finally` block that directly calls `close()` or `delete()` can throw a new exception and hide the original timeout or cancellation.

The failure boundary must answer four questions:

1. Who owns the source, sink, and partial artifact?
2. Does cancellation abort both the source and the sink?
3. Are close and delete called exactly once or several times?
4. Is cleanup failure reported alongside the original failure or does it replace it?

### A redirect is a credential boundary

**\[Source verified]** One flow attaches session credentials to the initial request, while another downloads from a presigned URL without session credentials. The file layer does not yet have a shared policy for scheme, trusted origin, effective port, or each redirect hop.

A standalone `session` or `anonymous` enum is not enough. `session` must be tied to an exact trusted origin. `presignedAnonymous` must never receive a session header. If a redirect to another origin is allowed, the adapter must create a new header map without credentials before sending the next hop; it must not copy the old request and strip credentials later.

The HTTP client article already explains HTTP auth snapshots, reserved headers, and safe telemetry. The file pipeline adds only the source and redirect policy for the byte stream.

{% content-ref url="/pages/r1BG7cXgqoay4tvlyEeO" %}
[Production HTTP Client for Flutter](/flutter/my-flutter/systems-realtime/production-http-client.md)
{% endcontent-ref %}

### Keeping a public path after verification still creates TOCTOU

If a verified handle exposes a `File` or path, another caller can change the bytes after verification but before upload, open, or share. Even when size stays the same, the digest can change. The artifact can also be replaced, swapped for a symlink, or raced against the handoff copy.

This is time-of-check/time-of-use: the verifier checks one object, while the consumer uses another object or another version of the same path. `VerifiedFileLease` must be the capability to read an owned snapshot, not a wrapper labelled `Verified` around a mutable path.

### External handoff and upload have separate outcomes

**\[Source verified]** One flow downloads an attachment to a temporary file and invokes an external opener, an export flow creates a temporary file and opens it, and some iOS flows invoke the share sheet. Open and share results are generally not mapped completely, and the bytes do not yet pass through a shared verified-only gate.

External-open success says only that the platform accepted the request. It does not prove that the receiving app read the file successfully, nor does it give the sending app authority to delete a copy retained by the receiver.

**\[Source verified]** Upload and attach-to-message do not yet share idempotency or status reconciliation. A UI retry can upload again after upload succeeds but the attach step fails. If the backend does not deduplicate, this risks creating duplicate objects; the source research has no runtime or dashboard evidence that this has occurred.

### Platform configuration does not replace device evidence

**\[Configured — Android]** The reference app uses `minSdk` 24 and `compileSdk`/`targetSdk` 36. Its manifest limits some legacy permissions by API level and configures the scoped-storage transition. The source still has a helper that writes directly to an external path instead of following a SAF/MediaStore policy.

**\[Configured — iOS]** The deployment target is iOS 15. The Plist and Podfile contain camera, microphone, and Photos descriptions or macros. This does not prove correct behavior for denied, restricted, limited, or add-only access, Files providers, or iPad sharing.

## Solution

### Separate unverified sources from verified leases

**\[Proposed design]** The Dart blocks below are consecutive parts of the same sample library. Real network, filesystem, parser, and platform adapters are injected through interfaces. The public surface returns no `File`, path, or writable sink.

I start with value types for MIME, verified metadata, and two distinct capabilities:

```dart
enum FileKind { pdf, wordDocument, spreadsheet, image, video }

enum FilePurpose { preview, upload, saveAs, externalOpen, share }

enum SensitiveUse { preview, upload, saveAs, externalOpen, share }

enum TempLifetime { screen, transfer, handoffTtl }

extension SensitiveUsePurpose on SensitiveUse {
  FilePurpose get purpose => switch (this) {
    SensitiveUse.preview => FilePurpose.preview,
    SensitiveUse.upload => FilePurpose.upload,
    SensitiveUse.saveAs => FilePurpose.saveAs,
    SensitiveUse.externalOpen => FilePurpose.externalOpen,
    SensitiveUse.share => FilePurpose.share,
  };
}

enum FileContractFailureKind {
  invalidMime,
  purposeNotAllowed,
  emptyOriginAllowlist,
  missingSessionCredential,
  invalidFilePolicy,
  contentNotAllowed,
}

final class FileContractFailure implements Exception {
  const FileContractFailure(this.kind);

  final FileContractFailureKind kind;
}

final class NormalizedMime {
  NormalizedMime._(this.value);

  final String value;

  static NormalizedMime parse(String raw) {
    final value = raw.split(';').first.trim().toLowerCase();
    final token = RegExp(r'^[a-z0-9!#$&^_.+-]+$');
    final parts = value.split('/');
    if (parts.length != 2 ||
        parts.any((part) => part.isEmpty || !token.hasMatch(part)) ||
        value.contains('*')) {
      throw const FileContractFailure(FileContractFailureKind.invalidMime);
    }
    return NormalizedMime._(value);
  }

  @override
  bool operator ==(Object other) =>
      other is NormalizedMime && value == other.value;

  @override
  int get hashCode => value.hashCode;
}

final class Sha256Digest {
  const Sha256Digest._fromVerifier(this.hex);

  final String hex;
}

final class VerifiedFileMetadata {
  const VerifiedFileMetadata._({
    required this.kind,
    required this.declaredMime,
    required this.detectedMime,
    required this.length,
    required this.digest,
  });

  final FileKind kind;
  final NormalizedMime? declaredMime;
  final NormalizedMime detectedMime;
  final int length;
  final Sha256Digest digest;
}

abstract interface class CancellationToken {
  bool get isCancelled;
  Future<void> get whenCancelled;
}

typedef UntrustedStreamOpener =
    Future<Stream<List<int>>> Function(CancellationToken cancel);
typedef UntrustedSourceCloser = Future<void> Function();

sealed class UnverifiedFileSource {
  const UnverifiedFileSource._();

  static UnverifiedFileSource stream({
    required String displayName,
    required NormalizedMime? declaredMime,
    required int? declaredLength,
    required UntrustedStreamOpener open,
    required UntrustedSourceCloser close,
  }) => _StreamFileSource(
    displayName: displayName,
    declaredMime: declaredMime,
    declaredLength: declaredLength,
    openSource: open,
    closeSource: close,
  );

  String get displayName;
  NormalizedMime? get declaredMime;
  int? get declaredLength;
  Future<Stream<List<int>>> open(CancellationToken cancel);
  Future<void> close();
}

final class _StreamFileSource extends UnverifiedFileSource {
  const _StreamFileSource({
    required this.displayName,
    required this.declaredMime,
    required this.declaredLength,
    required UntrustedStreamOpener openSource,
    required UntrustedSourceCloser closeSource,
  }) : _openSource = openSource,
       _closeSource = closeSource,
       super._();

  @override
  final String displayName;

  @override
  final NormalizedMime? declaredMime;

  @override
  final int? declaredLength;

  final UntrustedStreamOpener _openSource;
  final UntrustedSourceCloser _closeSource;

  @override
  Future<Stream<List<int>>> open(CancellationToken cancel) =>
      _openSource(cancel);

  @override
  Future<void> close() => _closeSource();
}

abstract interface class _OwnedReadCapability {
  String get displayName;
  TempLifetime get lifetime;

  Future<T> readScoped<T>({
    required VerifiedFileMetadata expected,
    required SensitiveUse use,
    required bool rerunParser,
    required Future<T> Function(Stream<List<int>> bytes) consume,
  });

  Future<void> disposeOnce();
}

sealed class VerifiedFileLease {
  const VerifiedFileLease._();

  VerifiedFileMetadata get metadata;
  String get displayName;
  TempLifetime get lifetime;

  Future<T> withValidatedStream<T>(
    SensitiveUse use,
    Future<T> Function(Stream<List<int>> bytes) consume,
  );

  Future<void> dispose();
}

final class _VerifiedFileLease extends VerifiedFileLease {
  _VerifiedFileLease._fromVerifier({
    required _OwnedReadCapability owned,
    required this.metadata,
    required Set<FilePurpose> allowedPurposes,
    required bool rerunParser,
  }) : _owned = owned,
       _allowedPurposes = Set.unmodifiable(allowedPurposes),
       _rerunParser = rerunParser,
       super._();

  final _OwnedReadCapability _owned;
  final Set<FilePurpose> _allowedPurposes;
  final bool _rerunParser;

  @override
  final VerifiedFileMetadata metadata;

  @override
  String get displayName => _owned.displayName;

  @override
  TempLifetime get lifetime => _owned.lifetime;

  @override
  Future<T> withValidatedStream<T>(
    SensitiveUse use,
    Future<T> Function(Stream<List<int>> bytes) consume,
  ) {
    if (!_allowedPurposes.contains(use.purpose)) {
      throw const FileContractFailure(
        FileContractFailureKind.purposeNotAllowed,
      );
    }
    return _owned.readScoped(
      expected: metadata,
      use: use,
      rerunParser: _rerunParser,
      consume: consume,
    );
  }

  @override
  Future<void> dispose() => _owned.disposeOnce();
}

abstract interface class VerifiedFileConsumer {
  Future<void> previewPdf(VerifiedFileLease file);
  Future<UploadReceipt> upload(VerifiedFileLease file);
  Future<ExternalOpenResult> openExternally(VerifiedFileLease file);
  Future<void> share(VerifiedFileLease file);
}
```

Non-null `VerifiedFileMetadata` exists only on a verified lease. Detected MIME and kind are typed values; declared MIME may be absent but cannot exist as an unnormalized string. `_VerifiedFileLease._fromVerifier` and `_OwnedReadCapability` are both private to the library.

The API acceptance tests include one valid fixture that must analyze cleanly and intentionally invalid fixtures that must fail analysis: invoking a private verified constructor, implementing a sealed type outside the library, passing `UnverifiedFileSource` to `upload`, or finding a public member with a `File`, path, or writable-sink type. The runner must fail if a negative fixture unexpectedly compiles.

The failure API snapshot must also reject any public field or constructor that accepts an arbitrary `String`, `Object`, or `StackTrace`. A result carries only an enum category; UI text is mapped from that category in the presentation layer and never taken from an exception message.

### Bind authentication to an exact origin and every redirect hop

**\[Proposed design]** `Origin` is a value type for scheme, normalized host, and effective port. The default HTTPS port and the equivalent explicit HTTPS port must compare equal; a different subdomain or port must produce a different origin.

```dart
enum RequestCredential { none, session }

enum OriginScheme { https }

enum SourceRejectReason {
  userInfo,
  nonHttps,
  invalidHost,
  trailingDotNotAllowed,
  ambiguousIpLiteral,
  unsafePort,
  originNotAllowed,
  downgrade,
  redirectLoop,
  tooManyRedirects,
  invalidLocation,
}

enum IdnaProfile { uts46NonTransitionalStd3 }

enum TrailingDotPolicy { reject, stripSingleRootDot }

enum IpLiteralPolicy { reject, exactOriginOnly }

enum HostKind { dns, ipv4, ipv6 }

abstract interface class HostNormalizer {
  HostNormalizationResult canonicalize(
    String rawHost, {
    required IdnaProfile profile,
  });
}

final class HostNormalizationResult {
  const HostNormalizationResult._fromAdapter({
    required this.asciiPreservingTrailingDots,
    required this.kind,
    required this.inputWasCanonicalIpLiteral,
    required this.hasIpv6Zone,
  });

  final String asciiPreservingTrailingDots;
  final HostKind kind;
  final bool inputWasCanonicalIpLiteral;
  final bool hasIpv6Zone;
}

final class SourcePolicyFailure implements Exception {
  const SourcePolicyFailure(this.reason);

  final SourceRejectReason reason;
}

final class NormalizedHost {
  const NormalizedHost._(this.ascii, this.kind);

  final String ascii;
  final HostKind kind;

  static NormalizedHost fromRaw({
    required String raw,
    required HostNormalizer normalizer,
    required TrailingDotPolicy trailingDotPolicy,
    required IpLiteralPolicy ipLiteralPolicy,
  }) {
    if (raw.isEmpty || RegExp(r'^\s|\s$').hasMatch(raw)) {
      throw const SourcePolicyFailure(SourceRejectReason.invalidHost);
    }
    late HostNormalizationResult normalized;
    try {
      normalized = normalizer.canonicalize(
        raw,
        profile: IdnaProfile.uts46NonTransitionalStd3,
      );
    } on Object {
      throw const SourcePolicyFailure(SourceRejectReason.invalidHost);
    }

    var ascii = normalized.asciiPreservingTrailingDots.toLowerCase();
    if (RegExp(r'^\s|\s$').hasMatch(ascii)) {
      throw const SourcePolicyFailure(SourceRejectReason.invalidHost);
    }
    final trailingDots = RegExp(r'\.*$').firstMatch(ascii)!.group(0)!.length;
    if (trailingDots > 1 ||
        trailingDots == 1 && trailingDotPolicy == TrailingDotPolicy.reject) {
      throw const SourcePolicyFailure(SourceRejectReason.trailingDotNotAllowed);
    }
    if (trailingDots == 1) {
      ascii = ascii.substring(0, ascii.length - 1);
    }

    if (normalized.kind != HostKind.dns) {
      if (ipLiteralPolicy == IpLiteralPolicy.reject ||
          !normalized.inputWasCanonicalIpLiteral ||
          normalized.hasIpv6Zone ||
          ascii.isEmpty) {
        throw const SourcePolicyFailure(SourceRejectReason.ambiguousIpLiteral);
      }
      return NormalizedHost._(ascii, normalized.kind);
    }

    final labels = ascii.split('.');
    final validLabel = RegExp(r'^[a-z0-9-]+$');
    final invalidDns =
        ascii.length > 253 ||
        labels.any(
          (label) =>
              label.isEmpty ||
              label.length > 63 ||
              label.startsWith('-') ||
              label.endsWith('-') ||
              !validLabel.hasMatch(label),
        );
    if (invalidDns) {
      throw const SourcePolicyFailure(SourceRejectReason.invalidHost);
    }
    return NormalizedHost._(ascii, HostKind.dns);
  }

  @override
  bool operator ==(Object other) =>
      other is NormalizedHost && ascii == other.ascii && kind == other.kind;

  @override
  int get hashCode => Object.hash(ascii, kind);
}

final class Origin {
  const Origin._(this.scheme, this.host, this.effectivePort);

  final OriginScheme scheme;
  final NormalizedHost host;
  final int effectivePort;

  static Origin fromUri({
    required Uri uri,
    required Set<int> allowedPorts,
    required HostNormalizer hostNormalizer,
    required TrailingDotPolicy trailingDotPolicy,
    required IpLiteralPolicy ipLiteralPolicy,
  }) {
    if (uri.userInfo.isNotEmpty) {
      throw const SourcePolicyFailure(SourceRejectReason.userInfo);
    }
    if (uri.scheme.toLowerCase() != 'https') {
      throw const SourcePolicyFailure(SourceRejectReason.nonHttps);
    }
    if (!uri.hasAuthority || uri.host.isEmpty) {
      throw const SourcePolicyFailure(SourceRejectReason.invalidHost);
    }
    final port = uri.hasPort ? uri.port : 443;
    if (!allowedPorts.contains(port)) {
      throw const SourcePolicyFailure(SourceRejectReason.unsafePort);
    }
    return Origin._(
      OriginScheme.https,
      NormalizedHost.fromRaw(
        raw: uri.host,
        normalizer: hostNormalizer,
        trailingDotPolicy: trailingDotPolicy,
        ipLiteralPolicy: ipLiteralPolicy,
      ),
      port,
    );
  }

  @override
  bool operator ==(Object other) =>
      other is Origin &&
      scheme == other.scheme &&
      host == other.host &&
      effectivePort == other.effectivePort;

  @override
  int get hashCode => Object.hash(scheme, host, effectivePort);
}

final class _RedirectFingerprint {
  const _RedirectFingerprint(this.keyedDigest);

  final String keyedDigest;

  @override
  bool operator ==(Object other) =>
      other is _RedirectFingerprint && keyedDigest == other.keyedDigest;

  @override
  int get hashCode => keyedDigest.hashCode;
}

abstract interface class _RedirectDigester {
  _RedirectFingerprint digestCanonicalResolvedTarget({
    required Uri resolvedLocation,
    required Origin normalizedOrigin,
  });
}

final class RedirectHistory {
  RedirectHistory._(this._digester);

  final _RedirectDigester _digester;
  final Set<_RedirectFingerprint> _visited = {};

  bool _alreadyVisitedAndRemember({
    required Uri resolvedLocation,
    required Origin normalizedOrigin,
  }) {
    final fingerprint = _digester.digestCanonicalResolvedTarget(
      resolvedLocation: resolvedLocation,
      normalizedOrigin: normalizedOrigin,
    );
    return !_visited.add(fingerprint);
  }
}

final class RequestAuthorization {
  const RequestAuthorization._(this.origin, this.credential);

  final Origin origin;
  final RequestCredential credential;
}

sealed class InitialRequestDecision {
  const InitialRequestDecision._();
}

final class AllowInitialRequest extends InitialRequestDecision {
  const AllowInitialRequest._(this.authorization) : super._();

  final RequestAuthorization authorization;
}

final class RejectInitialRequest extends InitialRequestDecision {
  const RejectInitialRequest._(this.reason) : super._();

  final SourceRejectReason reason;
}

sealed class RedirectDecision {
  const RedirectDecision._();
}

final class FollowRedirect extends RedirectDecision {
  const FollowRedirect._(this.authorization) : super._();

  final RequestAuthorization authorization;
}

final class RejectRedirect extends RedirectDecision {
  const RejectRedirect._(this.reason) : super._();

  final SourceRejectReason reason;
}

sealed class SourcePolicy {
  const SourcePolicy._();

  Set<Origin> get initialOrigins;
  Set<Origin> get allowedOrigins;
  RequestCredential credentialsFor(Origin target);

  InitialRequestDecision evaluateInitial({
    required Uri target,
    required RedirectHistory history,
    required Set<int> allowedPorts,
    required HostNormalizer hostNormalizer,
    required TrailingDotPolicy trailingDotPolicy,
    required IpLiteralPolicy ipLiteralPolicy,
  }) {
    try {
      final origin = Origin.fromUri(
        uri: target,
        allowedPorts: allowedPorts,
        hostNormalizer: hostNormalizer,
        trailingDotPolicy: trailingDotPolicy,
        ipLiteralPolicy: ipLiteralPolicy,
      );
      if (!initialOrigins.contains(origin)) {
        return const RejectInitialRequest._(
          SourceRejectReason.originNotAllowed,
        );
      }
      if (history._alreadyVisitedAndRemember(
        resolvedLocation: target,
        normalizedOrigin: origin,
      )) {
        return const RejectInitialRequest._(SourceRejectReason.redirectLoop);
      }
      return AllowInitialRequest._(
        RequestAuthorization._(origin, credentialsFor(origin)),
      );
    } on SourcePolicyFailure catch (failure) {
      return RejectInitialRequest._(failure.reason);
    }
  }

  RedirectDecision evaluateRedirect({
    required Origin previous,
    required Uri? resolvedLocation,
    required RedirectHistory history,
    required int hop,
    required int maxRedirects,
    required Set<int> allowedPorts,
    required HostNormalizer hostNormalizer,
    required TrailingDotPolicy trailingDotPolicy,
    required IpLiteralPolicy ipLiteralPolicy,
  }) {
    if (resolvedLocation == null) {
      return const RejectRedirect._(SourceRejectReason.invalidLocation);
    }
    if (hop <= 0 || hop > maxRedirects) {
      return const RejectRedirect._(SourceRejectReason.tooManyRedirects);
    }
    if (previous.scheme == OriginScheme.https &&
        resolvedLocation.scheme.toLowerCase() != 'https') {
      return const RejectRedirect._(SourceRejectReason.downgrade);
    }
    try {
      final origin = Origin.fromUri(
        uri: resolvedLocation,
        allowedPorts: allowedPorts,
        hostNormalizer: hostNormalizer,
        trailingDotPolicy: trailingDotPolicy,
        ipLiteralPolicy: ipLiteralPolicy,
      );
      if (!allowedOrigins.contains(origin)) {
        return const RejectRedirect._(SourceRejectReason.originNotAllowed);
      }
      if (history._alreadyVisitedAndRemember(
        resolvedLocation: resolvedLocation,
        normalizedOrigin: origin,
      )) {
        return const RejectRedirect._(SourceRejectReason.redirectLoop);
      }
      return FollowRedirect._(
        RequestAuthorization._(origin, credentialsFor(origin)),
      );
    } on SourcePolicyFailure catch (failure) {
      return RejectRedirect._(failure.reason);
    }
  }
}

final class SessionSameOriginSource extends SourcePolicy {
  SessionSameOriginSource._(this.trustedOrigin, this.anonymousRedirectOrigins)
    : super._();

  factory SessionSameOriginSource.validated({
    required Origin trustedOrigin,
    Set<Origin> anonymousRedirectOrigins = const {},
  }) => SessionSameOriginSource._(
    trustedOrigin,
    Set.unmodifiable(anonymousRedirectOrigins),
  );

  final Origin trustedOrigin;
  final Set<Origin> anonymousRedirectOrigins;

  @override
  Set<Origin> get initialOrigins => Set.unmodifiable({trustedOrigin});

  @override
  Set<Origin> get allowedOrigins =>
      Set.unmodifiable({trustedOrigin, ...anonymousRedirectOrigins});

  @override
  RequestCredential credentialsFor(Origin target) => target == trustedOrigin
      ? RequestCredential.session
      : RequestCredential.none;
}

final class PresignedAnonymousSource extends SourcePolicy {
  PresignedAnonymousSource._(this.allowedOrigins) : super._();

  factory PresignedAnonymousSource.validated({
    required Set<Origin> allowedOrigins,
  }) {
    if (allowedOrigins.isEmpty) {
      throw const FileContractFailure(
        FileContractFailureKind.emptyOriginAllowlist,
      );
    }
    return PresignedAnonymousSource._(Set.unmodifiable(allowedOrigins));
  }

  @override
  final Set<Origin> allowedOrigins;

  @override
  Set<Origin> get initialOrigins => allowedOrigins;

  @override
  RequestCredential credentialsFor(Origin target) => RequestCredential.none;
}

Map<String, String> headersForHop(
  RequestAuthorization authorization, {
  String? sessionCredential,
}) {
  final headers = <String, String>{'Accept': 'application/octet-stream'};
  if (authorization.credential == RequestCredential.session) {
    if (sessionCredential == null || sessionCredential.isEmpty) {
      throw const FileContractFailure(
        FileContractFailureKind.missingSessionCredential,
      );
    }
    headers['Authorization'] = sessionCredential;
  }
  return Map.unmodifiable(headers);
}
```

`headersForHop` always creates a new map. A cross-origin decision with `none` credentials therefore cannot accidentally inherit a session header from the previous hop. `PresignedAnonymousSource.credentialsFor` has no branch that returns `session`.

Host canonicalization fails closed under a fixed IDNA profile. `HostNormalizer` must return canonical ASCII while preserving the number of trailing dots for policy to decide. Input with leading or trailing whitespace is rejected instead of being `trim()`-transformed into another host. Trailing-dot policy has only two explicit choices: reject it, or strip exactly one root dot; two or more dots are always rejected. Valid Unicode and punycode forms must normalize to the same `NormalizedHost`. Noncanonical IPv4, IPv6 zones, and invalid IP literals are rejected; a canonical IP passes only when policy is `exactOriginOnly` and that exact `Origin` appears in the allowlist.

The request adapter creates `RedirectHistory` with a private digester whose key is random for each transfer. `evaluateInitial` records the initial target in history. `evaluateRedirect` accepts no independent fingerprint from the caller; it passes the normalized `resolvedLocation` itself to `_alreadyVisitedAndRemember`. The digester canonicalizes the scheme, host, effective port, path, and query, then retains only a keyed digest. Because the candidate is always computed from the target about to be sent and the private factory is not public, a caller cannot substitute a candidate to bypass the visited set.

The adapter resolves `Location` against the current URL and calls `evaluateRedirect` before sending. It rejects user-info, non-HTTPS, downgrade, unsafe ports, invalid or missing locations, origins outside the allowlist, loops, and excess hops. A decision retains only `Origin`, credential mode, and reason. The full URL and query exist only transiently inside the request adapter; they do not enter the lease, domain state, or logs. Telemetry uses an opaque transfer ID and a reason category.

### Validate `FilePolicy` in release builds too

An `assert` can be removed from a release build. The factory must validate caps, timeouts, redirect bounds, and allowlists with code that always runs:

```dart
final class FilePolicy {
  FilePolicy._({
    required this.allowedMimeByKind,
    required this.maxBytes,
    required this.allowedPurposes,
    required this.source,
    required this.allowedPorts,
    required this.trailingDotPolicy,
    required this.ipLiteralPolicy,
    required this.maxRedirects,
    required this.connectTimeout,
    required this.idleTimeout,
    required this.totalTimeout,
    required this.tempLifetime,
    required this.rerunParserBeforeSensitiveUse,
    required this.allowSanitizedDiagnostics,
  });

  factory FilePolicy.validated({
    required Map<FileKind, Set<NormalizedMime>> allowedMimeByKind,
    required int maxBytes,
    required Set<FilePurpose> allowedPurposes,
    required SourcePolicy source,
    required Set<int> allowedPorts,
    required TrailingDotPolicy trailingDotPolicy,
    required IpLiteralPolicy ipLiteralPolicy,
    required int maxRedirects,
    required Duration connectTimeout,
    required Duration idleTimeout,
    required Duration totalTimeout,
    required TempLifetime tempLifetime,
    bool rerunParserBeforeSensitiveUse = false,
    bool allowSanitizedDiagnostics = false,
  }) {
    final mimeSetsAreValid =
        allowedMimeByKind.isNotEmpty &&
        allowedMimeByKind.values.every((values) => values.isNotEmpty);
    final portsAreValid =
        allowedPorts.isNotEmpty &&
        allowedPorts.every((port) => port > 0 && port <= 65535);
    final originsUseAllowedPorts = source.allowedOrigins.every(
      (origin) => allowedPorts.contains(origin.effectivePort),
    );
    final ipPolicyMatchesOrigins =
        ipLiteralPolicy == IpLiteralPolicy.exactOriginOnly ||
        source.allowedOrigins.every(
          (origin) => origin.host.kind == HostKind.dns,
        );
    final timeoutsAreValid =
        connectTimeout > Duration.zero &&
        idleTimeout > Duration.zero &&
        totalTimeout > Duration.zero &&
        connectTimeout <= totalTimeout &&
        idleTimeout <= totalTimeout;
    if (!mimeSetsAreValid ||
        allowedPurposes.isEmpty ||
        source.allowedOrigins.isEmpty ||
        !portsAreValid ||
        !originsUseAllowedPorts ||
        !ipPolicyMatchesOrigins ||
        maxBytes <= 0 ||
        maxRedirects < 0 ||
        maxRedirects > 10 ||
        !timeoutsAreValid) {
      throw const FileContractFailure(
        FileContractFailureKind.invalidFilePolicy,
      );
    }

    final immutableMimes = Map<FileKind, Set<NormalizedMime>>.unmodifiable(
      allowedMimeByKind.map(
        (kind, mimes) => MapEntry(kind, Set.unmodifiable(mimes)),
      ),
    );
    return FilePolicy._(
      allowedMimeByKind: immutableMimes,
      maxBytes: maxBytes,
      allowedPurposes: Set.unmodifiable(allowedPurposes),
      source: source,
      allowedPorts: Set.unmodifiable(allowedPorts),
      trailingDotPolicy: trailingDotPolicy,
      ipLiteralPolicy: ipLiteralPolicy,
      maxRedirects: maxRedirects,
      connectTimeout: connectTimeout,
      idleTimeout: idleTimeout,
      totalTimeout: totalTimeout,
      tempLifetime: tempLifetime,
      rerunParserBeforeSensitiveUse: rerunParserBeforeSensitiveUse,
      allowSanitizedDiagnostics: allowSanitizedDiagnostics,
    );
  }

  final Map<FileKind, Set<NormalizedMime>> allowedMimeByKind;
  final int maxBytes;
  final Set<FilePurpose> allowedPurposes;
  final SourcePolicy source;
  final Set<int> allowedPorts;
  final TrailingDotPolicy trailingDotPolicy;
  final IpLiteralPolicy ipLiteralPolicy;
  final int maxRedirects;
  final Duration connectTimeout;
  final Duration idleTimeout;
  final Duration totalTimeout;
  final TempLifetime tempLifetime;
  final bool rerunParserBeforeSensitiveUse;
  final bool allowSanitizedDiagnostics;

  void requirePurpose(FilePurpose purpose) {
    if (!allowedPurposes.contains(purpose)) {
      throw const FileContractFailure(
        FileContractFailureKind.purposeNotAllowed,
      );
    }
  }

  void requireVerifiedContent(FileKind kind, NormalizedMime detectedMime) {
    final allowedMimes = allowedMimeByKind[kind];
    if (allowedMimes == null || !allowedMimes.contains(detectedMime)) {
      throw const FileContractFailure(
        FileContractFailureKind.contentNotAllowed,
      );
    }
  }
}
```

`maxRedirects == 0` is a valid configuration that forbids redirects. The map and every set are copied into immutable collections; a caller cannot validate them and then mutate an allowlist. The values in the sample are illustrative safety bounds, not business thresholds taken from the source.

### Stream, verify, seal, and finish exactly once

**\[Proposed design]** Calling `ingest` transfers ownership of the source to the pipeline. `_runs.claimOwnership` is the first step and, by contract, must always return a run synchronously without performing I/O; every setup operation that can fail is deferred to `initializeResources`. Purpose or declared-length rejection, run initialization, and open failure therefore all pass through the same terminal path. Before sealing, the verifier checks the initial purpose, counted length, declared and detected MIME, magic or container parser, and digest:

```dart
final class CopyResult {
  const CopyResult({
    required this.length,
    required this.digest,
    required this.prefix,
  });

  final int length;
  final Sha256Digest digest;
  final List<int> prefix;
}

final class DetectedContent {
  const DetectedContent({required this.kind, required this.mime});

  final FileKind kind;
  final NormalizedMime mime;
}

enum CleanupIssueKind {
  abortSource,
  abortSink,
  closeSource,
  closeSink,
  deletePartial,
  deleteSealedArtifact,
  durabilityCleanup,
}

final class CleanupIssue {
  const CleanupIssue(this.kind);

  final CleanupIssueKind kind;
}

enum FilePipelineFailureKind {
  purposeRejected,
  contractRejected,
  invalidDeclaredLength,
  declaredLengthOverLimit,
  cancelled,
  runInitialization,
  sourceOpen,
  partOpen,
  copy,
  flush,
  sync,
  close,
  verification,
  seal,
  leaseConstruction,
  cleanupWithoutPrimary,
  unexpected,
}

final class FilePipelineFailure implements Exception {
  FilePipelineFailure._(this.kind, Iterable<CleanupIssue> suppressedCleanup)
    : suppressedCleanup = List.unmodifiable(suppressedCleanup);

  factory FilePipelineFailure.primary(FilePipelineFailureKind kind) =>
      FilePipelineFailure._(kind, const []);

  final FilePipelineFailureKind kind;
  final List<CleanupIssue> suppressedCleanup;

  FilePipelineFailure withSuppressedCleanup(Iterable<CleanupIssue> issues) =>
      FilePipelineFailure._(kind, [...suppressedCleanup, ...issues]);
}

FilePipelineFailure _mapPipelineFailure(Object error) {
  if (error case FilePipelineFailure failure) return failure;
  if (error case FileContractFailure failure) {
    final kind = failure.kind == FileContractFailureKind.purposeNotAllowed
        ? FilePipelineFailureKind.purposeRejected
        : FilePipelineFailureKind.contractRejected;
    return FilePipelineFailure.primary(kind);
  }
  return FilePipelineFailure.primary(FilePipelineFailureKind.unexpected);
}

abstract interface class _SanitizedDiagnostics {
  void captureBestEffort({
    required FilePipelineFailureKind kind,
    required StackTrace stackTrace,
  });
}

abstract interface class _IngestRun {
  Future<void> initializeResources();

  Future<void> openSourceAndExclusivePart();

  Future<CopyResult> copyCountAndDigest({
    required int maxBytes,
    required Duration idleTimeout,
  });

  Future<void> flushCloseAndSyncPart();

  Future<DetectedContent> verifyClosedPart({
    required List<int> prefix,
    required NormalizedMime? declaredMime,
    required FilePolicy policy,
  });

  Future<_OwnedReadCapability> atomicSeal(String verifiedExtension);

  Future<VerifiedFileLease> finishSuccess(
    Future<VerifiedFileLease> Function() createLease,
  );

  Future<Never> finishFailure(FilePipelineFailure primary);
}

abstract interface class _IngestRunFactory {
  _IngestRun claimOwnership({
    required UnverifiedFileSource source,
    required FilePolicy policy,
    required CancellationToken cancel,
  });
}

final class FileGate {
  const FileGate(this._runs, this._diagnostics);

  final _IngestRunFactory _runs;
  final _SanitizedDiagnostics _diagnostics;

  Future<VerifiedFileLease> ingest({
    required UnverifiedFileSource source,
    required FilePolicy policy,
    required SensitiveUse requestedUse,
    required CancellationToken cancel,
  }) async {
    final run = _runs.claimOwnership(
      source: source,
      policy: policy,
      cancel: cancel,
    );
    try {
      policy.requirePurpose(requestedUse.purpose);
      final declaredLength = source.declaredLength;
      if (declaredLength != null && declaredLength < 0) {
        throw FilePipelineFailure.primary(
          FilePipelineFailureKind.invalidDeclaredLength,
        );
      }
      if (declaredLength != null && declaredLength > policy.maxBytes) {
        throw FilePipelineFailure.primary(
          FilePipelineFailureKind.declaredLengthOverLimit,
        );
      }

      await run.initializeResources();
      await run.openSourceAndExclusivePart();
      final copied = await run.copyCountAndDigest(
        maxBytes: policy.maxBytes,
        idleTimeout: policy.idleTimeout,
      );
      await run.flushCloseAndSyncPart();
      final detected = await run.verifyClosedPart(
        prefix: copied.prefix,
        declaredMime: source.declaredMime,
        policy: policy,
      );

      policy.requirePurpose(requestedUse.purpose);
      policy.requireVerifiedContent(detected.kind, detected.mime);
      final owned = await run.atomicSeal(_extensionFor(detected.kind));
      final metadata = VerifiedFileMetadata._(
        kind: detected.kind,
        declaredMime: source.declaredMime,
        detectedMime: detected.mime,
        length: copied.length,
        digest: copied.digest,
      );
      return await run.finishSuccess(
        () async => _VerifiedFileLease._fromVerifier(
          owned: owned,
          metadata: metadata,
          allowedPurposes: policy.allowedPurposes,
          rerunParser: policy.rerunParserBeforeSensitiveUse,
        ),
      );
    } on Object catch (error, stackTrace) {
      final primary = _mapPipelineFailure(error);
      if (policy.allowSanitizedDiagnostics) {
        try {
          _diagnostics.captureBestEffort(
            kind: primary.kind,
            stackTrace: stackTrace,
          );
        } on Object {
          // Diagnostic failure does not change the operational outcome.
        }
      }
      return await run.finishFailure(primary);
    }
  }
}

String _extensionFor(FileKind kind) => switch (kind) {
  FileKind.pdf => '.pdf',
  FileKind.wordDocument => '.docx',
  FileKind.spreadsheet => '.xlsx',
  FileKind.image => '.img',
  FileKind.video => '.media',
};
```

`_IngestRun` owns the source from `claimOwnership`, before any check that can throw. This method only assigns a reference to an owner object and is a total, non-throwing contract; implementations put I/O, allocator adapters, or fault-injectable setup in `initializeResources`. A factory that can throw while doing work before returning a run is invalid. `null` means the declared length is unknown; a negative value is `invalidDeclaredLength`, while a value above the cap is `declaredLengthOverLimit`. All three early-rejection paths call `finishFailure`, always attempt `source.close`, and do not open a `.part` file before one is needed.

The run has states `created → initializing → opening → copying → closing → verifying → sealing → ready`, plus terminal states `failed`, `cancelled`, and `disposed`. `finishSuccess` and `finishFailure` use the same mutex or completer. A second finish call only receives the first outcome; it does not commit or clean up again. Resource-initialization failures are represented in `initializeResources`, so even a fault-injected run-creation or setup failure still has a run that can close the source.

Commit begins only after the counted copy, sink flush, sink close, and the durability step required by platform policy. If an adapter cannot provide a guarantee equivalent to `fsync`, it returns a typed capability or result instead of claiming false success. Rename must be atomic within the same owned volume; a fallback copy still writes `.part`, flushes, closes, verifies, and only then publishes the final name.

On cancellation, the run records `FilePipelineFailureKind.cancelled` as the primary failure once, aborts the source and sink, awaits both closes, and deletes the partial artifact exactly once. Every `abort`, `close`, `deletePartial`, and sealed-artifact cleanup operation runs inside a non-throwing boundary. Cleanup only adds an allowlisted `CleanupIssueKind` to immutable `suppressedCleanup`; it stores no exception message or path and does not replace the original timeout, cancellation, or verifier failure.

A raw `Object` exists only in catch scope. `_mapPipelineFailure` preserves a known typed failure or maps any unknown object to `FilePipelineFailureKind.unexpected`; no object or message is attached to operational state. `StackTrace` is passed directly to the observability-boundary sanitizer only when `allowSanitizedDiagnostics` is enabled. The sanitizer receives a category, must redact before emission, and has its own failures ignored so they cannot hide the primary outcome.

The verifier, parser, rename, and lease constructor all run before terminal success. If any step throws, failure finishing cleans up the owned artifact, whether it is still `.part` or has been sealed without a lease being issued.

### Use an owned snapshot to prevent TOCTOU

`_OwnedReadCapability.readScoped` must perform these steps immediately before sensitive use:

1. Check `FilePurpose` through the lease before opening a reader.
2. Open the owned artifact with no-follow semantics and require a regular file.
3. Compare internal identity, length, and SHA-256 with `VerifiedFileMetadata`.
4. Run the signature or parser again when the risk policy requires it.
5. Pass a stream to the callback only after those checks pass.
6. Await callback completion and then cancel or close the reader in `finally`.

The stream is valid only inside the owned `Future` scope. A caller can retain the reference, but after the callback settles the reader is closed and further reads must fail. The capability serializes or rejects concurrent reads according to policy, and `dispose()` is idempotent.

If the platform cannot guarantee a stable descriptor or identity, the store creates a new snapshot in a protected directory, hashes while copying, seals it atomically, and then lets consumers read that exact snapshot. After sealing, the store exposes no writer API.

The consumer does not need to know a local path:

```dart
final class IdempotencyKey {
  const IdempotencyKey._generated(this.opaqueValue);

  final String opaqueValue;
}

final class UploadReceipt {
  const UploadReceipt._fromServer(this.opaqueId);

  final String opaqueId;
}

enum ExternalOpenResult { done, dismissed, noHandler, denied, unavailable }

abstract interface class UploadTransport {
  Future<UploadReceipt> send({
    required Stream<List<int>> bytes,
    required int length,
    required NormalizedMime mime,
    required IdempotencyKey idempotencyKey,
    required CancellationToken cancel,
  });
}

abstract interface class HandoffItem {}

abstract interface class HandoffStore {
  Future<HandoffItem> createSealedCopy({
    required Stream<List<int>> bytes,
    required VerifiedFileMetadata expected,
  });
}

abstract interface class PlatformHandoff {
  Future<ExternalOpenResult> share(HandoffItem item);
}

final class FileConsumers {
  const FileConsumers(this._upload, this._handoffStore, this._handoff);

  final UploadTransport _upload;
  final HandoffStore _handoffStore;
  final PlatformHandoff _handoff;

  Future<UploadReceipt> upload({
    required VerifiedFileLease file,
    required IdempotencyKey idempotencyKey,
    required CancellationToken cancel,
  }) => file.withValidatedStream(
    SensitiveUse.upload,
    (bytes) => _upload.send(
      bytes: bytes,
      length: file.metadata.length,
      mime: file.metadata.detectedMime,
      idempotencyKey: idempotencyKey,
      cancel: cancel,
    ),
  );

  Future<ExternalOpenResult> share(VerifiedFileLease file) =>
      file.withValidatedStream(SensitiveUse.share, (bytes) async {
        final item = await _handoffStore.createSealedCopy(
          bytes: bytes,
          expected: file.metadata,
        );
        return _handoff.share(item);
      });
}
```

Upload starts from the revalidated scoped stream. External sharing creates a `.part` file from that same stream, counts and hashes again, flushes, closes, and seals it before issuing a URI or share-sheet item. It never returns to the picker path or display name. If bytes mutate, the artifact is replaced, a symlink is swapped in, or the digest changes during a race, the capability invalidates the lease, cleans up the owned artifact, and does not invoke the upload, open, or share adapter.

### Retry an attachment at the correct stage

**\[Proposed design]** The idempotency key is generated once for a logical attachment. The remote receipt exists independently from the attach or send step:

```
picked(untrusted source)
          │
          ▼
validating → ready(verified lease + stable idempotency key)
                         │
                         ▼
                    uploading
                         │
                         ▼
                uploaded(remote receipt)
                         │
                         ▼
                 attaching receipt
                         │
                         ▼
                      complete

upload failure ─► retry upload with the same key or reconcile
attach failure ─► retry attach with the same receipt; do not upload again
cancel         ─► abort transport + clean up the lease by ownership
```

I represent stages with different invariants as sealed types. The attachment coordinator calls `IdempotencyKey._generated` only once; transitions retain the same value object instead of accepting a new string from the UI:

```dart
sealed class AttachmentState {
  const AttachmentState._();
}

final class PickedAttachment extends AttachmentState {
  const PickedAttachment({required this.source, required this.idempotencyKey})
    : super._();

  final UnverifiedFileSource source;
  final IdempotencyKey idempotencyKey;
}

final class ReadyAttachment extends AttachmentState {
  const ReadyAttachment({required this.file, required this.idempotencyKey})
    : super._();

  final VerifiedFileLease file;
  final IdempotencyKey idempotencyKey;
}

final class UploadingAttachment extends AttachmentState {
  const UploadingAttachment({required this.file, required this.idempotencyKey})
    : super._();

  final VerifiedFileLease file;
  final IdempotencyKey idempotencyKey;
}

final class UploadedAttachment extends AttachmentState {
  const UploadedAttachment({
    required this.file,
    required this.idempotencyKey,
    required this.receipt,
  }) : super._();

  final VerifiedFileLease file;
  final IdempotencyKey idempotencyKey;
  final UploadReceipt receipt;
}

final class AttachingAttachment extends AttachmentState {
  const AttachingAttachment({
    required this.file,
    required this.idempotencyKey,
    required this.receipt,
  }) : super._();

  final VerifiedFileLease file;
  final IdempotencyKey idempotencyKey;
  final UploadReceipt receipt;
}

final class CompletedAttachment extends AttachmentState {
  const CompletedAttachment(this.receipt) : super._();

  final UploadReceipt receipt;
}
```

A transport failure before receipt can be retried with the same key when the backend contract supports it. A timeout that leaves server commit status unknown must query or reconcile by key before resending. Once a receipt exists, an attach failure returns to `UploadedAttachment`; it does not return to `ReadyAttachment`.

The public backend contract must accept a stable idempotency key, return an opaque receipt, support status lookup, and compare digests for the same key. If the backend does not yet provide that contract, the client must not automatically retry a non-idempotent upload.

### Choose permissions, storage, and handoff by action

| Action                   | Android                                                         | iOS                                                          |
| ------------------------ | --------------------------------------------------------------- | ------------------------------------------------------------ |
| Pick a document          | System picker/SAF; no broad storage permission                  | System document picker/provider URL                          |
| Pick a photo or video    | Photo Picker when supported; narrowly scoped fallback           | PHPicker/Photos picker; handle limited access                |
| Camera/crop              | Request camera access on the user action; recover activity loss | Provide a purpose string; handle denied/restricted/cancelled |
| Temporary preview/upload | App-private cache/files                                         | App sandbox cache/Documents according to lifetime            |
| Save a document          | `ACTION_CREATE_DOCUMENT` lets the user choose a destination     | Export/share/document picker according to the use case       |
| Save app-generated media | MediaStore on modern API levels                                 | Add-only when the app only writes to Photos                  |
| External open/share      | Narrow content URI plus a temporary read grant                  | Sealed copy plus share sheet, with an iPad anchor            |

Permission is a state machine, not a boolean. The camera article explains rationale, denial, Settings recovery, and activity loss; the file pipeline reuses that boundary for pickers and croppers.

{% content-ref url="/pages/twFPlbi5JMfwEKL2J8tP" %}
[Safe QR/Barcode Scanning](/flutter/my-flutter/ui-media/qr-barcode-scanner-camera-permission.md)
{% endcontent-ref %}

Android needs testing across API bands 24–28, 29, 30–32, and 33–36. Document saving prefers SAF; the app does not request broad image or video permission merely so the user can select one item. Android external handoff uses the exact MIME, a narrow URI, and a temporary read grant.

iOS needs testing from deployment target 15 through a currently supported OS, on both iPhone and iPad. Save to Photos requests add-only access when the feature does not read the library. The share sheet needs a popover anchor on iPad. Handoff completion does not mean that the receiving app read or deleted the file.

### Test the contract, not only the widget

The pipeline's minimum test matrix is:

| Layer            | Primary cases                                                                                                                                                                                    | Assertion                                                                                                                                                                                       |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| API/analyzer     | Construct or subclass a verified lease; pass an unverified source to a consumer; expose a public `File`, path, or sink                                                                           | Misuse does not compile; the public API snapshot exposes no mutable capability                                                                                                                  |
| Policy           | Nonpositive cap or timeout; empty allowlist; negative or excessive redirect count; mutated input set; IP policy conflicting with the allowlist                                                   | Factory rejects in release; accepted policy is deeply immutable                                                                                                                                 |
| MIME/container   | Extension, MIME, or magic mismatch; truncated or polyglot input; invalid OOXML; archive expansion                                                                                                | Only a verified kind, MIME, and parser result passes within quota                                                                                                                               |
| Stream           | Missing or incorrect header; chunked body over cap; connect, idle, or total timeout                                                                                                              | Counted stream stops at the cap and returns a typed failure                                                                                                                                     |
| Redirect/auth    | Unicode↔punycode, case, one or multiple trailing dots, noncanonical IPv4, IPv6 zone, default or explicit port, subdomain, cross-origin, downgrade, loop, presigned; attempt to forge a candidate | Canonical equivalence follows policy; ambiguous host or IP fails closed; candidate has no public factory or parameter; session is exact-origin only, while anonymous receives no session header |
| Ingest ownership | Purpose rejection; negative or oversized declared length; run creation, initialization, or open failure; source close throws                                                                     | Run takes ownership before checks; every early failure calls finish and attempts close; cleanup does not hide the typed primary failure                                                         |
| Finish/cleanup   | Each read, write, flush, sync, close, verify, seal, construct, or delete step throws; finish, cancel, or dispose called twice                                                                    | One terminal outcome; primary and suppressed values are allowlisted enums only; no raw object, message, or path is stored; partial or sealed artifact is cleaned exactly once                   |
| TOCTOU           | Mutate, replace, symlink, rename race, same-size digest mismatch                                                                                                                                 | Lease is invalidated; no upload, preview, open, or share; owned artifact is cleaned up                                                                                                          |
| Upload stage     | Upload succeeds then attach fails; ambiguous timeout; retry, cancel, or double tap                                                                                                               | Receipt is reused; idempotency key stays the same; no parallel duplicate                                                                                                                        |
| Android/iOS      | Picker cancellation, permission states, SAF/MediaStore/Photos, external handler, iPad                                                                                                            | Least privilege, typed result, no crash or hang                                                                                                                                                 |
| Resource/backend | File near the cap, large decoded image or archive, same key with another digest, orphan expiry                                                                                                   | Resource budgets and server semantics are measured or defined separately                                                                                                                        |

Mock Web Server is suitable for checking chunked bodies, incorrect status codes, redirects, slow streams, and connection resets without calling a real backend.

{% content-ref url="/pages/iwqvNcdIuoIMnTEG7T2p" %}
[Mock Web Server for Service/API Tests](/flutter/my-flutter/quality-delivery/mock-web-server-service-api-test.md)
{% endcontent-ref %}

Fixtures must be generated or synthetic: minimal PDF, PNG, JPEG, and OOXML files, byte mismatches, and a local fake server. Do not use documents, media, filenames, payloads, or URLs from the real app.

### Evidence, versions, and current limitations

**\[Test evidence — source only]** Existing tests cover three loading/error/success states of one PDF page, three loading and account-selection cases of an export page, and three toolbar and rotation cases of an image view. They do not click download or verify bytes, MIME, signatures, filesystem behavior, permissions, upload retries, or handoff results.

**\[Test evidence — blocked]** The focused Flutter command stopped during dependency resolution because a private Git dependency had an unverified host key. Retrying without fetching dependencies used an incomplete cache or artifact and ended with `+0` tests. This is an environment blocker, not a product test failure; I do not report that “tests pass.”

**\[Configured]** Verified versions:

* Flutter 3.41.2; Dart `>=3.11.0 <4.0.0`.
* `http` 1.6.0; `dio` 5.9.2.
* `file_picker` 9.2.3; `image_picker` 0.8.9; `image_cropper` 11.0.0.
* `pdfx` 2.9.2; `path_provider` 2.1.5.
* `permission_handler` 12.0.1; `share_plus` 12.0.1; `open_filex` 4.7.0; `saver_gallery` 4.1.0.
* Android minimum SDK 24, compile/target SDK 36; iOS deployment target 15.

The lockfile proves only the dependency graph of the snapshot. It does not prove native picker, provider, permission, viewer, or sharing behavior on a device.

**\[Runtime/backend/dashboard unknown]** The following points still require separate evidence:

* The merged release manifest, URI provider, and storage behavior on each Android API band.
* The first iOS prompt; denied, restricted, limited, and add-only states; Files or iCloud; and the iPad popover.
* Real redirect and header traces, declared length, MIME, content disposition, and signed-URL expiry.
* Backend allowlists, signature or container parsing, antivirus, malware scanning or CDR, object ACLs, idempotency, status lookup, and orphan cleanup.
* Dashboards for duplicate uploads, MIME rejection, cleanup failure, or failed handoff.
* Heap use, disk quota, throughput, cancellation latency, decoded-image or archive budgets, and main-isolate jank.

Without that evidence, I cannot conclude that production has path traversal, out-of-memory failures, duplicate uploads, or missing backend scanning. I can only say that the source has partial guards and that the target contract in this article has not been implemented or verified end to end.

References used to check the boundary:

* [OWASP File Upload Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html)
* [RFC 6454 — The Web Origin Concept](https://www.rfc-editor.org/rfc/rfc6454)
* [CWE-367 — Time-of-check Time-of-use Race Condition](https://cwe.mitre.org/data/definitions/367.html)
* [Dart `HttpClientRequest.followRedirects`](https://api.dart.dev/dart-io/HttpClientRequest/followRedirects.html)
* [Android data and file storage overview](https://developer.android.com/training/data-storage)
* [Android Storage Access Framework](https://developer.android.com/training/data-storage/shared/documents-files)
* [Android secure file sharing](https://developer.android.com/training/secure-file-sharing/)
* [Apple Photos add-only access](https://developer.apple.com/documentation/photos/phaccesslevel/addonly)
* [Apple `UIActivityViewController`](https://developer.apple.com/documentation/uikit/uiactivityviewcontroller)
* [`file_picker` 9.2.3](https://pub.dev/packages/file_picker/versions/9.2.3)
* [`image_picker` 0.8.9](https://pub.dev/packages/image_picker/versions/0.8.9)
* [`share_plus` 12.0.1](https://pub.dev/packages/share_plus/versions/12.0.1)
* [`open_filex` 4.7.0 `ResultType`](https://pub.dev/documentation/open_filex/latest/open_filex/ResultType.html)

## Conclusion

A secure file pipeline neither begins at the picker button nor ends when a viewer opens. It begins by treating every source as untrusted, validating `FilePolicy` and `SourcePolicy` in release builds, enforcing the cap on the stream, reconciling MIME with magic bytes and a container parser, and sealing the bytes into an owned snapshot.

`VerifiedFileLease` is the capability for that snapshot, not a generic label around a mutable `File`. Before every preview, upload, save, open, or share operation, the lease checks purpose and revalidates identity, length, and digest; the callback stream lives only inside the `Future` owned by the lease. A mismatch invalidates the lease and stops the consumer.

Single-flight finish preserves cancellation or verifier failure as the primary outcome, while a cleanup issue is only a suppressed detail. Upload preserves a stable idempotency key and the remote receipt across stages. The backend still revalidates content and authorization; Android and iOS handoff still require device tests and least-privilege configuration.

This design fits an app with many file sources and consumers. For a prototype that only reads one bundled asset, the pipeline can be smaller, but the public API still should not let an extension, MIME header, or mutable path turn itself into proof of `Verified`.

[Buy Me a Coffee](https://buymeacoffee.com/ducmng12g) | [Support me on Ko-fi](https://ko-fi.com/I2I81AEJG8)
