Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions lib/core/services/ai_workspace_move.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// Move-as-access-control for the AI workspace (1.iii).
//
// Moving a file INTO `fula-ai-workspace` re-encrypts it under the workspace
// secret + forest-indexes it (the AI gains read). Moving it OUT re-encrypts it
// under the master KEK in a normal bucket and removes the workspace copy (the AI
// loses read). The owner keeps the file either way.
//
// This is the UI-free orchestration shared by rename + move, factored out of the
// cloud-files screen so the security-critical REVOKE path is unit-testable: a
// move-out must (1) verify the re-encrypted destination decrypts under the master
// KEK BEFORE deleting the only-AI copy, then (2) verify the AI copy is actually
// gone — and report `revoke incomplete` loudly otherwise (the AI keeps exact-key
// read until the delete lands, so a silent failure would leave access un-revoked).

import 'dart:typed_data';

import 'package:fula_files/core/services/fula_api.dart';
import 'package:fula_files/core/services/fula_api_service.dart'
show FulaApiService;

/// The outcome of [aiAwareMove], mapped to UI messaging by the caller.
enum AiMoveResult {
/// A normal move/rename between non-AI buckets (or within one).
moved,

/// Moved INTO the AI bucket — the AI can now read it.
grantedToAi,

/// Moved OUT of the AI bucket — AI access removed, and VERIFIED removed.
revokedFromAi,

/// The download / re-upload failed; the source is untouched.
copyFailed,

/// (Revoke) the re-encrypted destination did NOT decrypt under the master KEK;
/// the AI copy is KEPT rather than deleting the only readable copy.
verifyFailed,

/// The source delete failed. For a revoke this means AI access was NOT removed.
deleteFailed,

/// (Revoke) the delete returned OK but the AI copy still enumerates — access is
/// NOT actually revoked.
revokeIncomplete,
}

/// Copy → (revoke-verify) → delete → (revoke-verify-gone), routing each side to
/// the workspace client (the AI bucket) or the master-KEK client (everything
/// else). Pure over [FulaApi] (no UI / no global state), so it is unit-testable.
Future<AiMoveResult> aiAwareMove(
FulaApi api, {
required String srcBucket,
required String srcKey,
required String destBucket,
required String destKey,
String? contentType,
}) async {
final ws = FulaApiService.aiWorkspaceBucket;
final srcIsAi = srcBucket == ws;
final destIsAi = destBucket == ws;
final isRevoke = srcIsAi && !destIsAi; // moving OUT of the AI bucket

// 1. COPY: download from the right client → re-upload to the right client.
try {
final Uint8List bytes = srcIsAi
? await api.downloadWorkspaceObject(srcBucket, srcKey)
: await api.downloadObject(srcBucket, srcKey);
if (destIsAi) {
await api.uploadWorkspaceObject(destBucket, destKey, bytes,
contentType: contentType);
} else {
await api.uploadObject(destBucket, destKey, bytes,
contentType: contentType);
}
} catch (_) {
return AiMoveResult.copyFailed;
}

// 2. REVOKE SAFETY: verify the re-encrypted destination is readable under the
// master KEK BEFORE deleting the AI copy.
if (isRevoke) {
try {
await api.downloadObject(destBucket, destKey);
} catch (_) {
return AiMoveResult.verifyFailed;
}
}

// 3. DELETE the source from the right client.
try {
if (srcIsAi) {
await api.deleteWorkspaceObject(srcBucket, srcKey);
} else {
await api.deleteObject(srcBucket, srcKey);
}
} catch (_) {
return AiMoveResult.deleteFailed;
}

// 4. REVOKE VERIFY: confirm the AI copy is actually gone (no longer
// enumerable). A lingering entry means access was NOT revoked.
if (isRevoke) {
try {
final still = await api.listWorkspaceObjects(srcBucket, prefix: srcKey);
if (still.any((x) => x.key == srcKey)) {
return AiMoveResult.revokeIncomplete;
}
} catch (_) {
// Verification read failed; the delete returned OK, so treat as done.
}
}

return isRevoke
? AiMoveResult.revokedFromAi
: (destIsAi ? AiMoveResult.grantedToAi : AiMoveResult.moved);
}
32 changes: 26 additions & 6 deletions lib/core/services/category_listing.dart
Original file line number Diff line number Diff line change
Expand Up @@ -42,25 +42,45 @@ import 'package:fula_files/core/services/legacy_listing_cache.dart';
/// so a user's own object with a colliding key always overwrites an AI entry.
/// (AI keys `ai/images/x.jpg` and user keys `x.jpg` are disjoint, so a
/// collision can't actually occur — this is zero-cost belt-and-suspenders.)
/// - VALIDATED: only keys shaped `ai/<base>/...` are admitted. The MCP is an
/// - VALIDATED: only keys shaped `ai/<aiCategory>/...` (with the AI category
/// mapped to THIS FxFiles view, below) are admitted. The MCP is an
/// independently-versioned writer; a malformed or off-category key is DROPPED
/// rather than mis-filed.
///
/// CATEGORY MAPPING: the hosted MCP + the local fula-mcp write SINGULAR category
/// segments — `ai/document/`, `ai/note/`, `ai/image/`, … (cloudflare `classify.ts`
/// / fula-mcp `classify.rs`). FxFiles' category views are PLURAL (`documents`,
/// `images`, … = `FileCategory.bucketName`). [_aiCategoriesForBase] bridges them,
/// and folds the AI categories that have no `FileCategory` of their own
/// (note/link → documents, screenshot → images, file → other) into the closest view.
const Map<String, List<String>> _aiCategoriesForBase = {
'images': ['image', 'screenshot'],
'videos': ['video'],
'audio': ['audio'],
'documents': ['document', 'note', 'link'],
'other': ['file', 'other'],
// 'downloads' / 'archives' / 'starred' have no AI-workspace counterpart.
};

Future<void> _mergeAiWorkspaceInto(
FulaApi api,
String base,
Map<String, FulaObject> byKey,
) async {
final aiCats = _aiCategoriesForBase[base];
if (aiCats == null || aiCats.isEmpty) return; // no AI category maps to this view
if (!await api.hasAiConnection()) return;
// listWorkspaceObjects never throws — tolerant by contract.
// listWorkspaceObjects never throws — tolerant by contract. List the whole `ai/`
// scope once, then admit only the AI categories that belong in THIS view.
final aiItems = await api.listWorkspaceObjects(
FulaApiService.aiWorkspaceBucket,
prefix: 'ai/$base/',
prefix: 'ai/',
);
final cats = aiCats.toSet();
for (final o in aiItems) {
final parts = o.key.split('/');
// Require exactly `ai/<base>/<name...>` — drop anything else.
if (parts.length < 3 || parts[0] != 'ai' || parts[1] != base) {
debugPrint('listCategory: dropping off-shape AI key "${o.key}"');
// Require exactly `ai/<aiCategory>/<name...>` with a category in this view.
if (parts.length < 3 || parts[0] != 'ai' || !cats.contains(parts[1])) {
continue;
}
// Already tagged sourceBucket='fula-ai-workspace' by listWorkspaceObjects.
Expand Down
15 changes: 15 additions & 0 deletions lib/core/services/fula_api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,21 @@ abstract class FulaApi {
/// error; the tag-adoption caller catches it so a failure never blocks restore.
Future<Uint8List> downloadWorkspaceObject(String bucket, String key);

/// Upload + encrypt a file INTO the AI-workspace forest (the GRANT primitive
/// for moving a file into the AI bucket): written under the workspace secret +
/// forest-indexed, so the AI can list + read it. Throws on a genuine error.
Future<void> uploadWorkspaceObject(
String bucket,
String key,
Uint8List bytes, {
String? contentType,
});

/// Delete an object from the AI-workspace forest (the REVOKE primitive for
/// moving a file out of the AI bucket): removes BOTH the ciphertext and the
/// forest index entry, so the AI can no longer enumerate OR read it.
Future<void> deleteWorkspaceObject(String bucket, String key);

// share/collab APIs deliberately not in this interface yet — those
// scenarios are skeleton-only in this round. Adding them later is
// an additive change to this surface + the concrete service +
Expand Down
63 changes: 63 additions & 0 deletions lib/core/services/fula_api_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1252,6 +1252,69 @@ class FulaApiService implements FulaApi {
}
}

/// Upload + encrypt a file INTO the AI-workspace forest via the workspace
/// client (mirrors [uploadObject] on `_workspaceClient`, forest-tracked so the
/// object is enumerable). The GRANT primitive for moving a file INTO the AI
/// bucket: written under the workspace secret + indexed, so the AI can list +
/// read it. GATED + lazy like [downloadWorkspaceObject].
@override
Future<void> uploadWorkspaceObject(
String bucket,
String key,
Uint8List bytes, {
String? contentType,
}) async {
if (!await hasAiConnection()) {
throw FulaApiException('No AI connection — workspace upload skipped');
}
if (_workspaceClient == null) {
final secret = await _deriveWorkspaceSecretForRead();
if (secret != null) await initializeWorkspaceClient(secret);
}
if (_workspaceClient == null) {
throw FulaApiException('AI workspace client unavailable');
}
try {
await _ensureWorkspaceForestLoaded(bucket);
await fula.putFlat(
client: _workspaceClient!,
bucket: bucket,
path: key,
data: bytes.toList(),
contentType: contentType,
);
} catch (e) {
_workspaceLoadedForests.remove(bucket);
throw FulaApiException('Failed to upload workspace object: $e');
}
}

/// Delete an object from the AI-workspace forest via the workspace client
/// (mirrors [deleteObject] on `_workspaceClient`). The REVOKE primitive for
/// moving a file OUT of the AI bucket: `deleteFlat` removes BOTH the ciphertext
/// AND the forest index entry, so a compromised MCP can no longer enumerate OR
/// read the file.
@override
Future<void> deleteWorkspaceObject(String bucket, String key) async {
if (!await hasAiConnection()) {
throw FulaApiException('No AI connection — workspace delete skipped');
}
if (_workspaceClient == null) {
final secret = await _deriveWorkspaceSecretForRead();
if (secret != null) await initializeWorkspaceClient(secret);
}
if (_workspaceClient == null) {
throw FulaApiException('AI workspace client unavailable');
}
try {
await _ensureWorkspaceForestLoaded(bucket);
await fula.deleteFlat(client: _workspaceClient!, bucket: bucket, path: key);
} catch (e) {
_workspaceLoadedForests.remove(bucket);
throw FulaApiException('Failed to delete workspace object: $e');
}
}

/// Re-derive the AI-workspace secret for a READ path (lazy client build).
///
/// Reuses P13's [AiConnectionService.deriveWorkspaceSecret] verbatim — the
Expand Down
Loading