Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
13 changes: 13 additions & 0 deletions AIDevGallery.Utils/GitHubModelFileDetails.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,17 @@ public class GitHubModelFileDetails
/// </summary>
[JsonPropertyName("type")]
public string? Type { get; init; }

/// <summary>
/// Gets the encoded content of the file.
/// For LFS files, this contains the LFS pointer with SHA256.
/// </summary>
[JsonPropertyName("content")]
public string? Content { get; init; }

/// <summary>
/// Gets the encoding of the content (usually "base64").
/// </summary>
[JsonPropertyName("encoding")]
public string? Encoding { get; init; }
}
24 changes: 24 additions & 0 deletions AIDevGallery.Utils/HuggingFaceModelFileDetails.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,28 @@ public class HuggingFaceModelFileDetails
/// </summary>
[JsonPropertyName("path")]
public string? Path { get; init; }

/// <summary>
/// Gets the LFS (Large File Storage) information for the file.
/// </summary>
[JsonPropertyName("lfs")]
public HuggingFaceLfsInfo? Lfs { get; init; }
}

/// <summary>
/// LFS (Large File Storage) information for a Hugging Face file.
/// </summary>
public class HuggingFaceLfsInfo
{
/// <summary>
/// Gets the OID (SHA256 hash) of the file. Format: "sha256:abc123..."
/// </summary>
[JsonPropertyName("oid")]
public string? Oid { get; init; }

/// <summary>
/// Gets the size of the file in LFS.
/// </summary>
[JsonPropertyName("size")]
public long Size { get; init; }
}
22 changes: 22 additions & 0 deletions AIDevGallery.Utils/ModelFileDetails.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using System;

namespace AIDevGallery.Utils;

/// <summary>
Expand All @@ -27,4 +29,24 @@ public class ModelFileDetails
/// Gets the relative path to the file
/// </summary>
public string? Path { get; init; }

/// <summary>
/// Gets the expected SHA256 hash of the file.
/// For Hugging Face: from LFS oid field.
/// For GitHub: from LFS pointer file.
/// </summary>
public string? Sha256 { get; init; }

/// <summary>
/// Gets a value indicating whether this file should be verified for integrity (main model files like .onnx).
/// </summary>
public bool ShouldVerifyIntegrity => Name != null &&
(Name.EndsWith(".onnx", StringComparison.OrdinalIgnoreCase) ||
Name.EndsWith(".gguf", StringComparison.OrdinalIgnoreCase) ||
Name.EndsWith(".safetensors", StringComparison.OrdinalIgnoreCase));

/// <summary>
/// Gets a value indicating whether this file has a hash available for verification.
/// </summary>
public bool HasVerificationHash => !string.IsNullOrEmpty(Sha256);
}
69 changes: 62 additions & 7 deletions AIDevGallery.Utils/ModelInformationHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,55 @@ public static async Task<List<ModelFileDetails>> GetDownloadFilesFromGitHub(GitH
}

return files.Select(f =>
new ModelFileDetails()
{
string? sha256 = null;

if (f.Content != null && f.Encoding == "base64")
{
try
{
var decodedContent = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(f.Content));
sha256 = ParseLfsPointerSha256(decodedContent);
}
catch (FormatException)
{
Debug.WriteLine($"Failed to decode base64 content for {f.Path}");
}
}

return new ModelFileDetails()
{
DownloadUrl = f.DownloadUrl,
Size = f.Size,
Name = (f.Path ?? string.Empty).Split(["/"], StringSplitOptions.RemoveEmptyEntries).LastOrDefault(),
Path = f.Path
}).ToList();
Path = f.Path,
Sha256 = sha256
};
}).ToList();
}

/// <summary>
/// Parses a Git LFS pointer file content to extract the SHA256 hash.
/// </summary>
/// <param name="lfsPointerContent">The content of the LFS pointer file.</param>
/// <returns>The SHA256 hash if found, otherwise null.</returns>
private static string? ParseLfsPointerSha256(string lfsPointerContent)
{
if (string.IsNullOrEmpty(lfsPointerContent))
{
return null;
}

var lines = lfsPointerContent.Split(['\n', '\r'], StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines)
{
if (line.StartsWith("oid sha256:", StringComparison.OrdinalIgnoreCase))
{
return line.Substring("oid sha256:".Length).Trim();
}
}

return null;
}

/// <summary>
Expand Down Expand Up @@ -172,13 +214,26 @@ public static async Task<List<ModelFileDetails>> GetDownloadFilesFromHuggingFace
}

return hfFiles.Where(f => f.Type != "directory").Select(f =>
new ModelFileDetails()
{
string? sha256 = null;
if (f.Lfs?.Oid != null)
{
sha256 = f.Lfs.Oid;
if (sha256.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
{
sha256 = sha256.Substring(7);
}
}

return new ModelFileDetails()
{
DownloadUrl = $"https://huggingface.co/{hfUrl.Organization}/{hfUrl.Repo}/resolve/{hfUrl.Ref}/{f.Path}",
Size = f.Size,
Size = f.Lfs?.Size ?? f.Size,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LFS size is always reliable and available? this will not break existing downloads where Lfs is null?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question. The ?? operator should handle this: if Lfs is null (non-LFS files), it falls back to f.Size, which is the original logic. This change here is for some of my local HF validation logic which has been removed. We can revert it back. Let me clean it up.

Name = (f.Path ?? string.Empty).Split(["/"], StringSplitOptions.RemoveEmptyEntries).LastOrDefault(),
Path = f.Path
}).ToList();
Path = f.Path,
Sha256 = sha256
};
}).ToList();
}

/// <summary>
Expand Down
1 change: 1 addition & 0 deletions AIDevGallery.Utils/SourceGenerationContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ namespace AIDevGallery.Utils;
[JsonSourceGenerationOptions(WriteIndented = true, AllowTrailingCommas = true)]
[JsonSerializable(typeof(List<GitHubModelFileDetails>))]
[JsonSerializable(typeof(List<HuggingFaceModelFileDetails>))]
[JsonSerializable(typeof(HuggingFaceLfsInfo))]
internal partial class SourceGenerationContext : JsonSerializerContext
{
}
18 changes: 18 additions & 0 deletions AIDevGallery/Controls/DownloadProgressList.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,24 @@
Tag="{x:Bind}"
ToolTipService.ToolTip="Cancel"
Visibility="{x:Bind vm:DownloadableModel.VisibleWhenDownloading(Status), Mode=OneWay}" />
<Button
Grid.RowSpan="3"
Grid.Column="2"
Width="32"
Height="32"
Padding="0"
VerticalAlignment="Center"
AutomationProperties.Name="Verification failed - click to resolve"
Click="VerificationFailedClicked"
Style="{StaticResource SubtleButtonStyle}"
Tag="{x:Bind}"
ToolTipService.ToolTip="Verification failed - click to resolve"
Visibility="{x:Bind vm:DownloadableModel.VisibleWhenVerificationFailed(Status), Mode=OneWay}">
<FontIcon
FontSize="14"
Foreground="{ThemeResource SystemFillColorCautionBrush}"
Glyph="&#xE7BA;" />
</Button>
</Grid>
</toolkit:SettingsCard>
</DataTemplate>
Expand Down
57 changes: 57 additions & 0 deletions AIDevGallery/Controls/DownloadProgressList.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using AIDevGallery.ViewModels;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using System;
using System.Collections.ObjectModel;
using System.Linq;

Expand Down Expand Up @@ -86,4 +87,60 @@ private void ClearHistory_Click(object sender, RoutedEventArgs e)
}
}
}

private async void VerificationFailedClicked(object sender, RoutedEventArgs e)
{
if (sender is Button button && button.Tag is DownloadableModel downloadableModel)
{
var dialog = new ContentDialog
{
Title = "Integrity verification failed",
Content = new StackPanel
{
Spacing = 12,
Children =
{
new TextBlock
{
Text = "The downloaded model file(s) did not match the expected hash. This could indicate the file was corrupted during download or has been tampered with.",
TextWrapping = TextWrapping.Wrap
},
new TextBlock
{
Text = downloadableModel.VerificationFailureMessage ?? "Unknown verification error",
TextWrapping = TextWrapping.Wrap,
Foreground = (Microsoft.UI.Xaml.Media.Brush)Application.Current.Resources["SystemFillColorCautionBrush"],
FontSize = 12
},
new TextBlock
{
Text = "Would you like to keep the model anyway or delete it?",
TextWrapping = TextWrapping.Wrap
}
}
},
PrimaryButtonText = "Delete model",
SecondaryButtonText = "Keep anyway",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the security rationale for allowing users to keep potentially
compromised models?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Initially I didn't implement the "Keep" option, but I added it after considering that some users are developers who understand the risks. There are also legitimate scenarios where verification may fail (e.g., hash metadata out of sync on the source), and without this option, developers would have no way to use a model if verification consistently fails. The default action is "Delete" and keeping requires explicit user choice. Do you think we should not give developers any option to keep verification-failed files?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense. Since our target audience is developers, I agree we shouldn't block them completely.
To mitigate the risk, I suggest 2 improvements:
1.If user chooses to "keep anyway", its status should not simply change to Completed (which makes it look identical to a normal model). It will be better to display an “Unverified” label or a warning icon in the UI to remind users that this model carries potential risks.
2. The current warning msg is quite technical but doesn't clearly explain the security implications to users who may not fully understand what match the expected hash means. maybe we can consider like: "This could indicate file tampering or corruption. You will be using an unverified model that may behave unexpectedly"

CloseButtonText = "Cancel",
DefaultButton = ContentDialogButton.Primary,
XamlRoot = this.XamlRoot
};

var result = await dialog.ShowAsync();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When you close the app under this logic, an intermediate state issue should occur.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice catch. I added cleanup logic to handle verification-failed models on app close.


if (result == ContentDialogResult.Primary)
{
// Delete the model
downloadableModel.DeleteVerificationFailedModel();
downloadProgresses.Remove(downloadableModel);
}
else if (result == ContentDialogResult.Secondary)
{
// Keep the model despite verification failure
downloadableModel.KeepVerificationFailedModel();
}

// If Cancel, do nothing - leave the item in the list
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using Microsoft.Diagnostics.Telemetry;
using Microsoft.Diagnostics.Telemetry.Internal;
using System;
using System.Diagnostics.Tracing;

namespace AIDevGallery.Telemetry.Events;

[EventData]
internal class ModelIntegrityVerificationFailedEvent : EventBase
{
internal ModelIntegrityVerificationFailedEvent(string modelUrl, string fileName, string expectedHash, string actualHash)
{
ModelUrl = modelUrl;
FileName = fileName;
ExpectedHash = expectedHash;
ActualHash = actualHash;
EventTime = DateTime.UtcNow;
}

public string ModelUrl { get; private set; }
public string FileName { get; private set; }
public string ExpectedHash { get; private set; }
public string ActualHash { get; private set; }
public DateTime EventTime { get; private set; }

public override PartA_PrivTags PartA_PrivTags => PrivTags.ProductAndServiceUsage;

public override void ReplaceSensitiveStrings(Func<string?, string?> replaceSensitiveStrings)
{
}

public static void Log(string modelUrl, string fileName, string expectedHash, string actualHash)
{
TelemetryFactory.Get<ITelemetry>().LogError(
"ModelIntegrityVerificationFailed_Event",
LogLevel.Info,
new ModelIntegrityVerificationFailedEvent(modelUrl, fileName, expectedHash, actualHash));
}
}
Loading