using System.Text; using AIStudio.Settings; namespace AIStudio.Chat; /// /// Attachment whose Markdown file is owned and lifecycle-managed by the media feature. /// /// Display file name. /// Absolute staged or chat-owned path. /// Current file size. /// Original media file name used in the title and stem. /// Whether the file still lives in operation staging. public sealed record ManagedTranscriptAttachment(string FileName, string FilePath, long FileSizeBytes, string OriginalFileName, bool IsStaged) : FileAttachment(FileAttachmentType.DOCUMENT, FileName, FilePath, FileSizeBytes) { /// Refreshes the path-derived name and current file size. public override FileAttachment Normalize() { var size = File.Exists(this.FilePath) ? new FileInfo(this.FilePath).Length : 0; return this with { FileName = Path.GetFileName(this.FilePath), FileSizeBytes = size }; } /// Creates a transcript in an operation-specific staging directory. /// Original media path. /// Provider transcript. /// The staged managed attachment. public static async Task CreateStagedAsync(string originalPath, string transcript) { var operationDirectory = Path.Combine(SettingsManager.DataDirectory!, "media-staging", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(operationDirectory); var originalFileName = Path.GetFileName(originalPath); var stagingPath = Path.Combine(operationDirectory, $"{Guid.NewGuid():N}.md"); await WriteMarkdownAsync(stagingPath, originalFileName, transcript); return FromPath(stagingPath, originalFileName, isStaged: true); } /// Writes transcript Markdown to a temporary file and atomically publishes it. /// Final managed target path. /// Original media file name. /// Provider transcript. /// The chat-owned managed attachment. internal static async Task CreateAtomicAsync(string targetPath, string originalFileName, string transcript) { Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); var temporaryPath = Path.Combine(Path.GetDirectoryName(targetPath)!, $".{Guid.NewGuid():N}.tmp"); try { await WriteMarkdownAsync(temporaryPath, originalFileName, transcript); File.Move(temporaryPath, targetPath); return FromPath(targetPath, originalFileName, isStaged: false); } finally { if (File.Exists(temporaryPath)) File.Delete(temporaryPath); } } /// Deletes a file only when its canonical path has an exact managed structure. /// Candidate managed attachment. /// Whether an owned file was deleted. public static bool TryDeleteOwnedFile(FileAttachment attachment) { if (attachment is not ManagedTranscriptAttachment managed || !File.Exists(managed.FilePath)) return false; var fileInfo = new FileInfo(managed.FilePath); var fullFilePath = Path.GetFullPath(fileInfo.FullName); var fullDataRoot = Path.GetFullPath(SettingsManager.DataDirectory!); var relative = Path.GetRelativePath(fullDataRoot, fullFilePath); if (Path.IsPathRooted(relative) || relative == ".." || relative.StartsWith($"..{Path.DirectorySeparatorChar}", PathComparison)) return false; if (fileInfo.LinkTarget is not null || HasLinkedDirectory(fileInfo.Directory, fullDataRoot)) return false; var segments = relative.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); var isStaging = segments is ["media-staging", _, _] && Guid.TryParseExact(segments[1], "N", out _) && !string.IsNullOrWhiteSpace(segments[2]); var isTemporaryChatTranscript = segments is ["tempChats", _, _, _, _] && Guid.TryParse(segments[1], out _) && segments[2] == "attachments" && segments[3] == "transcripts"; var isWorkspaceChatTranscript = segments is ["workspaces", _, _, _, _, _] && Guid.TryParse(segments[1], out _) && Guid.TryParse(segments[2], out _) && segments[3] == "attachments" && segments[4] == "transcripts"; if (!isStaging && !isTemporaryChatTranscript && !isWorkspaceChatTranscript) return false; File.Delete(fullFilePath); var parent = Path.GetDirectoryName(fullFilePath); if (isStaging && parent is not null && Directory.Exists(parent) && !Directory.EnumerateFileSystemEntries(parent).Any()) Directory.Delete(parent); return true; } /// Rejects paths traversing any symbolic-link or junction directory below the data root. private static bool HasLinkedDirectory(DirectoryInfo? directory, string fullDataRoot) { while (directory is not null && !string.Equals(Path.GetFullPath(directory.FullName), fullDataRoot, PathComparison)) { if (directory.LinkTarget is not null) return true; directory = directory.Parent; } return directory is null; } /// Normalizes an original stem using Unicode scalar values and cross-platform rules. /// Original media file name. /// A non-empty stem containing at most 80 Unicode text characters. internal static string NormalizeOriginalStem(string originalFileName) { var stem = Path.GetFileNameWithoutExtension(originalFileName).Normalize(NormalizationForm.FormC); var normalized = new StringBuilder(); var textCharacters = 0; foreach (var rune in stem.EnumerateRunes()) { if (textCharacters == 80) break; var replacement = Rune.IsControl(rune) || rune.Value is '/' or '\\' or '<' or '>' or ':' or '"' or '|' or '?' or '*' ? new Rune('-') : rune; normalized.Append(replacement); textCharacters++; } var result = normalized.ToString().Trim(' ', '.', '-'); return string.IsNullOrWhiteSpace(result) ? "media" : result; } /// Creates an attachment record from a file already written to disk. private static ManagedTranscriptAttachment FromPath(string path, string originalFileName, bool isStaged) => new( Path.GetFileName(path), path, new FileInfo(path).Length, originalFileName, isStaged); /// Writes localized transcript Markdown without a UTF-8 byte-order mark. private static async Task WriteMarkdownAsync(string path, string originalFileName, string transcript) { var markdown = $""" # Transcription: {originalFileName} {transcript.Trim()} """; await File.WriteAllTextAsync(path, markdown, new UTF8Encoding(false)); } /// Gets the platform path comparison used for canonical containment checks. private static StringComparison PathComparison => OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; }