Files
jobtrackingapp/JobTrackerApi/Services/AccountDataExportService.cs
T
cesnimda 1ec9dd037e
CI and Deploy / test (pull_request) Successful in 5m13s
CI and Deploy / deploy (pull_request) Has been skipped
feat(export): add readable account archive
2026-08-15 18:36:40 +02:00

443 lines
28 KiB
C#

using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Services;
public sealed record AccountDataExportArtifact(string StoragePath, string DownloadFileName);
public sealed class AccountDataExportService(
JobTrackerContext db,
AppPaths paths,
IAttachmentStorage attachmentStorage,
TimeProvider timeProvider)
{
private const string SchemaVersion = "jobtracker.user-export.v1";
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
ReferenceHandler = ReferenceHandler.IgnoreCycles,
};
public async Task<AccountDataExportArtifact> CreateAsync(string ownerUserId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(ownerUserId);
var user = await db.Users.AsNoTracking().FirstOrDefaultAsync(item => item.Id == ownerUserId, cancellationToken)
?? throw new InvalidOperationException("The account no longer exists.");
var generatedAt = timeProvider.GetUtcNow();
var ownerKey = AppPaths.GetOwnerStorageKey(ownerUserId);
var outputRoot = Path.Combine(paths.DataRoot, "AccountExports", ownerKey);
Directory.CreateDirectory(outputRoot);
var outputPath = Path.Combine(outputRoot, $"{Guid.NewGuid():N}.zip");
var warnings = new List<string>();
var entries = new List<ManifestEntry>();
try
{
await using (var output = new FileStream(outputPath, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None, 64 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan))
using (var archive = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: false, Encoding.UTF8))
{
async Task AddJsonAsync(string entryName, object value, int itemCount)
{
var bytes = JsonSerializer.SerializeToUtf8Bytes(value, value.GetType(), JsonOptions);
await AddBytesAsync(archive, entries, entryName, bytes, "data", itemCount, cancellationToken);
}
var roles = await (from userRole in db.UserRoles.AsNoTracking()
join role in db.Roles.AsNoTracking() on userRole.RoleId equals role.Id
where userRole.UserId == ownerUserId
orderby role.Name
select role.Name!).ToListAsync(cancellationToken);
var claims = await db.UserClaims.AsNoTracking()
.Where(item => item.UserId == ownerUserId)
.OrderBy(item => item.ClaimType)
.Select(item => new { item.ClaimType, item.ClaimValue })
.ToListAsync(cancellationToken);
var logins = await db.UserLogins.AsNoTracking()
.Where(item => item.UserId == ownerUserId)
.OrderBy(item => item.LoginProvider)
.Select(item => new { item.LoginProvider, item.ProviderDisplayName })
.ToListAsync(cancellationToken);
await AddJsonAsync("data/account.json", new
{
user.Id,
user.UserName,
user.Email,
user.EmailConfirmed,
user.PhoneNumber,
user.PhoneNumberConfirmed,
user.FirstName,
user.LastName,
user.DisplayName,
user.PendingEmail,
user.PendingEmailRequestedAtUtc,
user.ProfileCvText,
user.ProfileCvStructureJson,
user.CurrentCvUploadArtifactId,
user.CurrentCvExtractionRunId,
user.CurrentCvProfileVersion,
user.GoogleSubject,
user.GoogleEmail,
user.GoogleLinkedAt,
user.MicrosoftSubject,
user.MicrosoftTenantId,
user.MicrosoftObjectId,
user.MicrosoftEmail,
user.MicrosoftLinkedAt,
user.TotpEnabledAtUtc,
user.StripeCustomerId,
user.StripeSubscriptionId,
user.StripeSubscriptionStatus,
user.StripeLastEventCreatedUtc,
user.AiEnabled,
user.ExternalAiProcessingAllowed,
Roles = roles,
Claims = claims,
ExternalLogins = logins,
}, 1);
var companies = await db.Companies.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.Id).ToListAsync(cancellationToken);
var opportunities = await db.Jobs.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.Id).ToListAsync(cancellationToken);
var applications = await db.JobApplications.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.Id).ToListAsync(cancellationToken);
var applicationIds = applications.Select(item => item.Id).ToList();
await AddJsonAsync("data/companies.json", companies, companies.Count);
await AddJsonAsync("data/opportunities.json", opportunities, opportunities.Count);
await AddJsonAsync("data/applications.json", applications, applications.Count);
var correspondence = await db.Correspondences.IgnoreQueryFilters().AsNoTracking().Where(item => applicationIds.Contains(item.JobApplicationId)).OrderBy(item => item.Id).ToListAsync(cancellationToken);
var events = await db.JobEvents.IgnoreQueryFilters().AsNoTracking().Where(item => applicationIds.Contains(item.JobApplicationId)).OrderBy(item => item.Id).ToListAsync(cancellationToken);
var attachments = await db.Attachments.IgnoreQueryFilters().AsNoTracking().Where(item => applicationIds.Contains(item.JobApplicationId)).OrderBy(item => item.Id).ToListAsync(cancellationToken);
await AddJsonAsync("data/correspondence.json", correspondence, correspondence.Count);
await AddJsonAsync("data/job-events.json", events, events.Count);
await AddJsonAsync("data/attachments.json", attachments.Select(item => new
{
item.Id,
item.JobApplicationId,
item.FileName,
item.UploadDate,
item.FileType,
item.FileSize,
item.Purpose,
item.UseForAi,
}).ToList(), attachments.Count);
var careerProfile = await db.CareerProfiles.IgnoreQueryFilters().AsNoTracking().FirstOrDefaultAsync(item => item.OwnerUserId == ownerUserId, cancellationToken);
var careerProfileId = careerProfile?.Id;
var careerVersions = await db.CareerProfileVersions.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.Version).ToListAsync(cancellationToken);
var careerExperiences = await db.CareerExperiences.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.SortOrder).ToListAsync(cancellationToken);
var careerEducation = await db.CareerEducations.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.SortOrder).ToListAsync(cancellationToken);
var careerSkills = await db.CareerSkills.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.SortOrder).ToListAsync(cancellationToken);
var careerProjects = await db.CareerProjects.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.SortOrder).ToListAsync(cancellationToken);
var careerCertifications = await db.CareerCertifications.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.SortOrder).ToListAsync(cancellationToken);
var careerLanguages = await db.CareerLanguages.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.SortOrder).ToListAsync(cancellationToken);
await AddJsonAsync("data/career.json", new
{
Profile = careerProfile,
Versions = careerVersions,
Experiences = careerExperiences,
Education = careerEducation,
Skills = careerSkills,
Projects = careerProjects,
Certifications = careerCertifications,
Languages = careerLanguages,
}, (careerProfileId is null ? 0 : 1) + careerVersions.Count + careerExperiences.Count + careerEducation.Count + careerSkills.Count + careerProjects.Count + careerCertifications.Count + careerLanguages.Count);
var variants = await db.CvVariants.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.Id).ToListAsync(cancellationToken);
var variantVersions = await db.CvVariantVersions.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.Id).ToListAsync(cancellationToken);
var artifacts = await db.CvUploadArtifacts.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.Id).ToListAsync(cancellationToken);
var extractionRuns = await db.CvExtractionRuns.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.Id).ToListAsync(cancellationToken);
await AddJsonAsync("data/cv.json", new
{
Variants = variants,
VariantVersions = variantVersions,
UploadArtifacts = artifacts.Select(item => new
{
item.Id,
item.OriginalFileName,
item.StoredFileName,
item.MimeType,
item.ByteSize,
item.Sha256,
item.UploadedAtUtc,
}),
ExtractionRuns = extractionRuns,
}, variants.Count + variantVersions.Count + artifacts.Count + extractionRuns.Count);
var tailoredDrafts = await db.TailoredCvDrafts.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.Id).ToListAsync(cancellationToken);
var interviewNotes = await db.InterviewPrepNotes.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.Id).ToListAsync(cancellationToken);
var aiNotes = await db.AiWorkspaceNotes.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.Id).ToListAsync(cancellationToken);
var aiInteractions = await db.AiInteractions.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.Id).ToListAsync(cancellationToken);
var checklist = await db.ApplicationChecklistItems.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.Id).ToListAsync(cancellationToken);
var coverLetters = await db.CoverLetterVersions.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.Id).ToListAsync(cancellationToken);
var interviewItems = await db.InterviewPrepItems.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.Id).ToListAsync(cancellationToken);
var emailDrafts = await db.EmailDrafts.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.CreatedAtUtc).ToListAsync(cancellationToken);
var emailAttempts = await db.EmailSendAttempts.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.CreatedAtUtc)
.Select(item => new EmailSendAttemptExport(item.Id, item.JobApplicationId, item.Provider, item.ClientRequestId, item.Status, item.ProviderMessageId, item.FailureCategory, item.CreatedAtUtc, item.StartedAtUtc, item.CompletedAtUtc))
.ToListAsync(cancellationToken);
await AddJsonAsync("data/application-workspace.json", new
{
TailoredCvDrafts = tailoredDrafts,
InterviewPrepNotes = interviewNotes,
AiWorkspaceNotes = aiNotes,
AiInteractions = aiInteractions,
ChecklistItems = checklist,
CoverLetterVersions = coverLetters,
InterviewPrepItems = interviewItems,
EmailDrafts = emailDrafts,
EmailSendAttempts = emailAttempts,
}, tailoredDrafts.Count + interviewNotes.Count + aiNotes.Count + aiInteractions.Count + checklist.Count + coverLetters.Count + interviewItems.Count + emailDrafts.Count + emailAttempts.Count);
var operations = await db.UserOperations.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.CreatedAtUtc)
.Select(item => new
{
item.Id,
item.TaskType,
item.Status,
item.Priority,
item.EntitlementDecision,
item.PrivacyPolicy,
item.SubjectType,
item.SubjectId,
item.Provider,
item.Model,
item.AttemptCount,
item.MaxAttempts,
item.CreatedAtUtc,
item.AvailableAtUtc,
item.StartedAtUtc,
item.CompletedAtUtc,
item.DeadlineAtUtc,
item.CancellationRequestedAtUtc,
item.ProgressStage,
item.ProgressPercent,
item.FailureCategory,
item.FailureMessage,
item.ResultReference,
}).ToListAsync(cancellationToken);
var notifications = await db.UserNotifications.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.CreatedAtUtc).ToListAsync(cancellationToken);
await AddJsonAsync("data/operations.json", new { Operations = operations, Notifications = notifications }, operations.Count + notifications.Count);
var gmail = await db.GmailConnections.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId)
.Select(item => new { item.GmailAddress, item.Scope, item.ConnectedAt, item.LastSyncedAt, item.LastSyncAttemptedAt, item.LastSyncSucceededAt, item.LastSyncMode, item.LastSyncSource, item.LastSyncStatus, item.LastSyncError }).ToListAsync(cancellationToken);
var microsoft = await db.MicrosoftGraphConnections.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId)
.Select(item => new { item.MailAddress, item.Scope, item.ConnectedAt, item.LastSyncedAt, item.LastSyncAttemptedAt, item.LastSyncSucceededAt, item.LastSyncMode, item.LastSyncSource, item.LastSyncStatus, item.LastSyncError }).ToListAsync(cancellationToken);
var imap = await db.ImapConnections.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId)
.Select(item => new { item.Host, item.Port, item.UseSsl, item.Username, item.ConnectedAt, item.LastSyncedAt, item.LastSyncAttemptedAt, item.LastSyncSucceededAt, item.LastSyncMode, item.LastSyncSource, item.LastSyncStatus, item.LastSyncError }).ToListAsync(cancellationToken);
var reviewDecisions = await db.GmailReviewDecisions.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.Id).ToListAsync(cancellationToken);
var userRules = await db.UserRuleSettings.IgnoreQueryFilters().AsNoTracking().FirstOrDefaultAsync(item => item.OwnerUserId == ownerUserId, cancellationToken);
await AddJsonAsync("data/settings-and-providers.json", new
{
UserRules = userRules,
GmailConnections = gmail,
MicrosoftConnections = microsoft,
ImapConnections = imap,
GmailReviewDecisions = reviewDecisions,
}, (userRules is null ? 0 : 1) + gmail.Count + microsoft.Count + imap.Count + reviewDecisions.Count);
var sessions = await db.UserSessions.IgnoreQueryFilters().AsNoTracking().Where(item => item.UserId == ownerUserId).OrderBy(item => item.Id)
.Select(item => new { item.DeviceLabel, item.CreatedAtUtc, item.LastSeenAtUtc, item.ExpiresAtUtc, item.RevokedAtUtc }).ToListAsync(cancellationToken);
var trustedDevices = await db.TrustedDevices.IgnoreQueryFilters().AsNoTracking().Where(item => item.UserId == ownerUserId).OrderBy(item => item.Id)
.Select(item => new { item.DeviceLabel, item.CreatedAtUtc, item.LastSeenAtUtc, item.ExpiresAtUtc }).ToListAsync(cancellationToken);
var recoveryCodeCount = await db.TwoFactorRecoveryCodes.IgnoreQueryFilters().AsNoTracking().CountAsync(item => item.UserId == ownerUserId, cancellationToken);
await AddJsonAsync("data/security-metadata.json", new
{
TwoFactorEnabled = !string.IsNullOrWhiteSpace(user.TotpSecretEncrypted),
RecoveryCodeCount = recoveryCodeCount,
Sessions = sessions,
TrustedDevices = trustedDevices,
}, sessions.Count + trustedDevices.Count + recoveryCodeCount);
foreach (var attachment in attachments)
{
await AddOwnedFileAsync(archive, entries, warnings, attachment.FilePath, $"files/attachments/{attachment.Id}/{SafeSegment(attachment.FileName)}", "attachment", attachmentStorage.IsManagedPath, cancellationToken);
}
foreach (var artifact in artifacts)
{
await AddOwnedFileAsync(archive, entries, warnings, artifact.StoragePath, $"files/cv-artifacts/{artifact.Id}/{SafeSegment(artifact.OriginalFileName)}", "cv-artifact", path => IsManagedPath(paths.CvArtifactsRoot, path), cancellationToken);
}
await AddAvatarAsync(archive, entries, warnings, paths, ownerUserId, user.AvatarImageDataUrl, cancellationToken);
await AddDirectoryAsync(archive, entries, warnings, paths.GetOwnerCvExportsRoot(ownerUserId), "files/generated-cv", "generated-cv", cancellationToken);
await AddDirectoryAsync(archive, entries, warnings, paths.GetOwnerDailyExportsRoot(null, ownerUserId), "files/daily-exports", "daily-export", cancellationToken);
const string readme = """
Jobjakt readable account export
This ZIP contains user-readable JSON plus owned files. manifest.json lists every entry,
byte size, SHA-256 checksum and any unavailable file warnings.
Excluded secrets: password/security/concurrency hashes, TOTP secrets, recovery-code and
trusted-device token hashes, session IDs, OAuth access/refresh tokens, IMAP passwords,
operation lease tokens, email payload hashes, data-protection keys and global settings.
External providers may retain mailbox, billing or model-service data under their own
policies. Application logs and immutable backups are not edited by this export. Their
exact retention remains an operator/legal decision documented in BLOCKERS.md.
""";
await AddBytesAsync(archive, entries, "README.txt", Encoding.UTF8.GetBytes(readme), "documentation", 1, cancellationToken);
var manifest = new
{
SchemaVersion,
GeneratedAtUtc = generatedAt,
AccountId = ownerUserId,
EntryCount = entries.Count,
Entries = entries,
Warnings = warnings,
Exclusions = new[]
{
"authentication secrets and hashes",
"provider credentials and access/refresh tokens",
"global application settings and data-protection keys",
"other users' data",
"application logs, immutable backups and external-provider retained data",
"unattributable legacy generated files",
},
};
var manifestBytes = JsonSerializer.SerializeToUtf8Bytes(manifest, JsonOptions);
var manifestEntry = archive.CreateEntry("manifest.json", CompressionLevel.Optimal);
await using var manifestStream = manifestEntry.Open();
await manifestStream.WriteAsync(manifestBytes, cancellationToken);
}
return new AccountDataExportArtifact(outputPath, $"jobjakt-account-export-{generatedAt:yyyyMMdd-HHmmss}.zip");
}
catch
{
try { File.Delete(outputPath); } catch { }
throw;
}
}
private static async Task AddOwnedFileAsync(
ZipArchive archive,
ICollection<ManifestEntry> entries,
ICollection<string> warnings,
string sourcePath,
string entryName,
string category,
Func<string, bool> isManaged,
CancellationToken cancellationToken)
{
if (!isManaged(sourcePath))
{
warnings.Add($"Excluded unsafe {category} path for {entryName}.");
return;
}
if (!File.Exists(sourcePath))
{
warnings.Add($"Owned {category} file was unavailable: {entryName}.");
return;
}
await AddFileAsync(archive, entries, sourcePath, entryName, category, cancellationToken);
}
private static async Task AddDirectoryAsync(ZipArchive archive, ICollection<ManifestEntry> entries, ICollection<string> warnings, string root, string entryRoot, string category, CancellationToken cancellationToken)
{
if (!Directory.Exists(root)) return;
foreach (var path in Directory.EnumerateFiles(root, "*", new EnumerationOptions
{
RecurseSubdirectories = true,
IgnoreInaccessible = true,
AttributesToSkip = FileAttributes.ReparsePoint,
}))
{
var relative = Path.GetRelativePath(root, path);
if (relative.StartsWith("..", StringComparison.Ordinal))
{
warnings.Add($"Excluded unsafe {category} path.");
continue;
}
var entryName = $"{entryRoot}/{string.Join('/', relative.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Select(SafeSegment))}";
await AddFileAsync(archive, entries, path, entryName, category, cancellationToken);
}
}
private static async Task AddAvatarAsync(ZipArchive archive, ICollection<ManifestEntry> entries, ICollection<string> warnings, AppPaths paths, string ownerUserId, string? storedAvatar, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(storedAvatar)) return;
if (storedAvatar.StartsWith("file:", StringComparison.Ordinal))
{
var path = storedAvatar[5..];
var root = Path.Combine(paths.DataRoot, "Avatars", AppPaths.GetOwnerStorageKey(ownerUserId));
await AddOwnedFileAsync(archive, entries, warnings, path, $"files/avatar/{SafeSegment(Path.GetFileName(path))}", "avatar", candidate => IsManagedPath(root, candidate), cancellationToken);
return;
}
if (storedAvatar.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
var comma = storedAvatar.IndexOf(',');
if (comma > 0 && storedAvatar[..comma].Contains(";base64", StringComparison.OrdinalIgnoreCase))
{
try
{
var bytes = Convert.FromBase64String(storedAvatar[(comma + 1)..]);
await AddBytesAsync(archive, entries, "files/avatar/avatar", bytes, "avatar", 1, cancellationToken);
return;
}
catch (FormatException) { }
}
}
warnings.Add("The profile avatar was stored in an unsupported format and could not be included.");
}
private static async Task AddFileAsync(ZipArchive archive, ICollection<ManifestEntry> entries, string sourcePath, string entryName, string category, CancellationToken cancellationToken)
{
await using var source = new FileStream(sourcePath, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan);
var checksum = Convert.ToHexString(await SHA256.HashDataAsync(source, cancellationToken)).ToLowerInvariant();
source.Position = 0;
var entry = archive.CreateEntry(entryName.Replace('\\', '/'), CompressionLevel.Optimal);
await using var target = entry.Open();
await source.CopyToAsync(target, cancellationToken);
entries.Add(new ManifestEntry(entry.FullName, source.Length, checksum, category, 1));
}
private static async Task AddBytesAsync(ZipArchive archive, ICollection<ManifestEntry> entries, string entryName, byte[] bytes, string category, int itemCount, CancellationToken cancellationToken)
{
var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal);
await using var target = entry.Open();
await target.WriteAsync(bytes, cancellationToken);
entries.Add(new ManifestEntry(entry.FullName, bytes.LongLength, Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(), category, itemCount));
}
private static bool IsManagedPath(string root, string path)
{
if (string.IsNullOrWhiteSpace(path)) return false;
try
{
var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
var fullRoot = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var fullPath = Path.GetFullPath(path);
if (!fullPath.StartsWith(fullRoot + Path.DirectorySeparatorChar, comparison)) return false;
var current = Path.GetDirectoryName(fullPath);
while (!string.IsNullOrWhiteSpace(current) && !string.Equals(current, fullRoot, comparison))
{
if (Directory.Exists(current) && (File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) return false;
current = Path.GetDirectoryName(current);
}
return !File.Exists(fullPath) || (File.GetAttributes(fullPath) & FileAttributes.ReparsePoint) == 0;
}
catch
{
return false;
}
}
private static string SafeSegment(string? value)
{
var candidate = Path.GetFileName(value ?? string.Empty).Trim();
foreach (var invalid in Path.GetInvalidFileNameChars()) candidate = candidate.Replace(invalid, '_');
if (candidate.Length > 120) candidate = candidate[..120];
return string.IsNullOrWhiteSpace(candidate) || candidate is "." or ".." ? "file" : candidate;
}
private sealed record ManifestEntry(string Path, long Bytes, string Sha256, string Category, int ItemCount);
}