Repository: ical-org/ical.net
Pull request #748: Update `PRODID` and `VERSION` property handling
Author: login: axunonb
Created: 2025-03-06T08:48:26Z | Merged: 2025-03-11T07:20:33Z

Issue: `Calendar` has setters for `ProductId` and `Version` which are overridden with fixed values when serializing.

- When creating a new `Calendar` instance, `ProductId` and `Version` contain default values
- When Deserializing an iCalendar, `ProductId` and `Version` will be taken from the input
- `ProductId` and `Version` can be overridden by user code. An attempt to set as an empty string will throw.
- Update the default `PRODID` property `LibraryMetadata.ProdId` to include the ical.net assembly version. Example: "PRODID:-//github.com/ical-org/ical.net//NONSGML ical.net 5.4.3//EN"
- Modified `CalendarSerializer.SerializeToString` so that the `ProdId` or `Version` set by users do not get overridden
- Add an xmldoc description about the purpose of `ProdId` and `Version`, and about the risks when modified
- Add unit tests

Resolves #531 

Linked issue #531: Setting own ProductId or Version has no effect
Setting a own ProductId or Version on the Calendar has no effect. The ProductId and Version properties are replaced on the serialization with the library meta data.

```
var calendar = new Calendar
{
    ProductId = "MyCalendarTool",
    Version ="1.0"
};
```

https://github.com/rianjs/ical.net/blob/6c03c42bd9e040622ffaa240be856dc531a20823/src/Ical.Net/Serialization/CalendarSerializer.cs#L25-L38
Linked issue comments: user: login: akordowski
created_at: 2024-10-14T05:38:03Z
body: @axunonb Closing without any comment on this? Can you say if it is a bug or do I make something wrong? If it is a bug is it fixed, will be it fixed or don't? Thank you in advance.
author_association: NONE
user: login: axunonb
created_at: 2024-10-14T07:36:22Z
body: We closed the issue after 3 years of inactivity.
As the ProductId has a setter, it could be expected, that changing would have  an effect - which is not the case.
If you'd like to contribute a PR this is more than welcome.
author_association: COLLABORATOR
user: login: akordowski
created_at: 2024-10-14T08:35:12Z
body: So the `Version` and `ProductId` properties are meant not to be changed? They should be then only getters, right?
author_association: NONE
user: login: axunonb
created_at: 2024-10-14T22:25:06Z
body: Correct. To clean up, we could make `ProductId ` to only have a getter, or to process the value set correctly (ensure the [specification](https://www.kanzaki.com/docs/ical/prodid.html))
We'd prefer the 2nd option, and a PR is appreciated.
author_association: COLLABORATOR
user: login: akordowski
created_at: 2024-10-15T08:46:10Z
body: I would rather prefer to be able to override the properties with my own values. If the properties are not overridden then the library default values could be used. The specification don't states that it cannot be set to user values. At the moment it is an limitation by the library. What do you think?
author_association: NONE
user: login: axunonb
created_at: 2024-10-15T15:06:13Z
body: Changing the `ProductId` to a RFC-compliant value is fine.
With `Version` you are prone to get issues with client apps. They usually expect 2.0. So we won't open this to be changed. See `LibraryMetadata.Version`
author_association: COLLABORATOR
user: login: akordowski
created_at: 2024-10-15T16:30:28Z
body: > With Version you are prone to get issues with client apps. They usually expect 2.0. So we won't open this to be changed.

Ok, that makes sense. Then the `Version` property should be a getter with the default value.

> Changing the ProductId to a RFC-compliant value is fine.

So it would be ok for you if the property could be set to a custom value?
author_association: NONE
user: login: axunonb
created_at: 2025-03-06T07:51:06Z
body: @minichma As there was no PR submitted by @akordowski I'll do that today.
I can't see a reason why changing the PRODID should be excluded, as the serialized string can be changed anyway.
TBD: I'd include the FileVersion dynamically, instead of using a fixed version literal (currently 4.0).
author_association: COLLABORATOR

Diff:
diff --git a/Ical.Net.Tests/DeserializationTests.cs b/Ical.Net.Tests/DeserializationTests.cs
index ce4f885ed..8a326f1f6 100644
--- a/Ical.Net.Tests/DeserializationTests.cs
+++ b/Ical.Net.Tests/DeserializationTests.cs
@@ -588,4 +588,28 @@ public void KeepApartDtEndAndDuration_Tests(bool useDtEnd)
             Assert.That(calendar.Events.Single().Duration != null, Is.EqualTo(!useDtEnd));
         });
     }
+
+    [Test]
+    public void CalendarWithMissingProdIdOrVersion_ShouldLeavePropertiesInvalid()
+    {
+        var ics = """
+                      BEGIN:VCALENDAR
+                      BEGIN:VEVENT
+                      DTSTART:20070406T230000Z
+                      DTEND:20070407T010000Z
+                      END:VEVENT
+                      END:VCALENDAR
+                      """;
+
+        var calendar = Calendar.Load(ics);
+        var deserialized = new CalendarSerializer(calendar).SerializeToString();
+
+        Assert.Multiple(() =>
+        {
+            Assert.That(calendar.ProductId, Is.EqualTo(null));
+            Assert.That(calendar.Version, Is.EqualTo(null));
+            // The serialized calendar should not contain the PRODID or VERSION properties, which are required
+            Assert.That(deserialized, Does.Not.Contain("PRODID:").And.Not.Contains("VERSION:"));
+        });
+    }
 }
diff --git a/Ical.Net.Tests/SerializationTests.cs b/Ical.Net.Tests/SerializationTests.cs
index 9b4c0975f..df5ea7519 100644
--- a/Ical.Net.Tests/SerializationTests.cs
+++ b/Ical.Net.Tests/SerializationTests.cs
@@ -525,20 +525,33 @@ public void TestRRuleUntilSerialization()
         Assert.That(!until.EndsWith("Z"), Is.True);
     }
 
-    [Test(Description = "PRODID and VERSION should use ical.net values instead of preserving deserialized values")]
-    public void LibraryMetadataTests()
+    [Test]
+    public void ProductId_and_Version_CanBeChanged()
     {
         var c = new Calendar
         {
             ProductId = "FOO",
-            Version = "BAR"
+            Version = "BAR",
         };
+
         var serialized = new CalendarSerializer().SerializeToString(c);
-        var expectedProdid = $"PRODID:{LibraryMetadata.ProdId}";
-        Assert.That(serialized.Contains(expectedProdid, StringComparison.Ordinal), Is.True);
+        
+        Assert.Multiple(() =>
+        {
+            Assert.That(serialized, Does.Contain($"PRODID:{c.ProductId}"));
+            Assert.That(serialized, Does.Contain($"VERSION:{c.Version}"));
+        });
+    }
 
-        var expectedVersion = $"VERSION:{LibraryMetadata.Version}";
-        Assert.That(serialized.Contains(expectedVersion, StringComparison.Ordinal), Is.True);
+    [Test]
+    public void ProductId_and_Version_HaveDefaultValues()
+    {
+        var c = new Calendar();
+        Assert.Multiple(() =>
+        {
+            Assert.That(c.ProductId, Is.EqualTo(LibraryMetadata.ProdId));
+            Assert.That(c.Version, Is.EqualTo(LibraryMetadata.Version));
+        });
     }
 
     [Test]
diff --git a/Ical.Net/Calendar.cs b/Ical.Net/Calendar.cs
index 30fd3565c..6e6801f42 100644
--- a/Ical.Net/Calendar.cs
+++ b/Ical.Net/Calendar.cs
@@ -61,12 +61,16 @@ public static IList<T> Load<T>(string ical)
     /// </summary>
     public Calendar()
     {
-        Name = Components.Calendar;
+        // Note: ProductId and Version Property values will be empty before _deserialization_
+        ProductId = LibraryMetadata.ProdId;
+        Version = LibraryMetadata.Version;
+
         Initialize();
     }
 
     private void Initialize()
     {
+        Name = Components.Calendar;
         _mUniqueComponents = new UniqueComponentListProxy<IUniqueComponent>(Children);
         _mEvents = new UniqueComponentListProxy<CalendarEvent>(Children);
         _mTodos = new UniqueComponentListProxy<Todo>(Children);
@@ -144,16 +148,36 @@ public override int GetHashCode()
     public virtual ICalendarObjectList<VTimeZone> TimeZones => _mTimeZones;
 
     /// <summary>
-    /// A collection of <see cref="Todo"/> components in the iCalendar.
+    /// A collection of <see cref="CalendarComponents.Todo"/> components in the iCalendar.
     /// </summary>
     public virtual IUniqueComponentList<Todo> Todos => _mTodos;
 
+    /// <summary>
+    /// Gets or sets the version of the iCalendar definition. The default is <see cref="LibraryMetadata.Version"/>
+    /// as per RFC 5545 Section 3.7.4 and must be specified.
+    /// <para/>
+    /// It specifies the identifier corresponding to the highest version number of the iCalendar specification
+    /// that is required in order to interpret the iCalendar object.
+    /// <para/>
+    /// <b>Do not change unless you are sure about the consequences.</b>
+    /// <para/>
+    /// The default value does not apply to deserialized objects.
+    /// </summary>
     public virtual string Version
     {
         get => Properties.Get<string>("VERSION");
         set => Properties.Set("VERSION", value);
     }
 
+    /// <summary>
+    /// Gets or sets the product ID of the iCalendar, which typically contains the name of the software
+    /// that created the iCalendar. The default is <see cref="LibraryMetadata.ProdId"/>.
+    /// <para/>
+    /// <b>Be careful when setting a custom value</b>, as it is free-form text that must conform to the iCalendar specification
+    /// (RFC 5545 Section 3.7.3). The product ID must be specified.
+    /// <para/>
+    /// The default value does not apply to deserialized objects.
+    /// </summary>
     public virtual string ProductId
     {
         get => Properties.Get<string>("PRODID");
diff --git a/Ical.Net/Constants.cs b/Ical.Net/Constants.cs
index 10db33276..a8b78cf1e 100644
--- a/Ical.Net/Constants.cs
+++ b/Ical.Net/Constants.cs
@@ -3,7 +3,9 @@
 // Licensed under the MIT license.
 //
 
+#nullable enable
 using System;
+using System.Diagnostics;
 
 namespace Ical.Net;
 
@@ -124,7 +126,7 @@ public class SerializationConstants
 }
 
 /// <summary>
-/// Status codes available to an <see cref="Components.Event"/> item
+/// Status codes available to an <see cref="CalendarComponents.CalendarEvent"/> item
 /// </summary>
 public static class EventStatus
 {
@@ -137,7 +139,7 @@ public static class EventStatus
 }
 
 /// <summary>
-/// Status codes available to a <see cref="Todo"/> item.
+/// Status codes available to a <see cref="CalendarComponents.Todo"/> item.
 /// </summary>
 public static class TodoStatus
 {
@@ -152,7 +154,7 @@ public static class TodoStatus
 }
 
 /// <summary>
-/// Status codes available to a <see cref="Journal"/> entry.
+/// Status codes available to a <see cref="CalendarComponents.Journal"/> entry.
 /// </summary>
 public static class JournalStatus
 {
@@ -235,8 +237,32 @@ public static class TransparencyType
 
 public static class LibraryMetadata
 {
+    private static readonly string _assemblyVersion = GetAssemblyVersion();
+
+    /// <summary>
+    /// The <c>VERSION</c> property for iCalendar objects generated by this library (RFC 5545 Section 3.7.4),
+    /// unless overridden by user code.
+    /// </summary>
     public const string Version = "2.0";
-    public static readonly string ProdId = "-//github.com/ical-org/ical.net//NONSGML ical.net 4.0//EN";
+
+    /// <summary>
+    /// The default <c>PRODID</c> property for iCalendar objects generated by this library (RFC 5545 Section 3.7.3),
+    /// unless overridden by user code.
+    /// <remarks>
+    /// The text between the double slashes represents the organization or software that created the iCalendar object.
+    /// </remarks>
+    /// </summary>
+    public static string ProdId => $"-//github.com/ical-org/ical.net//NONSGML ical.net {_assemblyVersion}//EN";
+
+    private static string GetAssemblyVersion()
+    {
+        var assembly = typeof(LibraryMetadata).Assembly;
+        var fileVersionInfo = FileVersionInfo.GetVersionInfo(assembly.Location);
+        // Prefer the file version, but fall back to the assembly version if it's not available.
+        return fileVersionInfo.FileVersion
+               ?? assembly.GetName().Version?.ToString() // will only change for major versions
+               ?? "1.0.0.0";
+    }
 }
 
 public static class CalendarScales
diff --git a/Ical.Net/Serialization/CalendarSerializer.cs b/Ical.Net/Serialization/CalendarSerializer.cs
index 53d105578..ff3194d37 100644
--- a/Ical.Net/Serialization/CalendarSerializer.cs
+++ b/Ical.Net/Serialization/CalendarSerializer.cs
@@ -27,20 +27,6 @@ public CalendarSerializer(SerializationContext ctx) : base(ctx) { }
 
     protected override IComparer<ICalendarProperty> PropertySorter => new CalendarPropertySorter();
 
-    public override string SerializeToString(object obj)
-    {
-        if (obj is Calendar)
-        {
-            // If we're serializing a calendar, we should indicate that we're using ical.net to do the work
-            var calendar = (Calendar) obj;
-            calendar.Version = LibraryMetadata.Version;
-            calendar.ProductId = LibraryMetadata.ProdId;
-
-            return base.SerializeToString(calendar);
-        }
-
-        return base.SerializeToString(obj);
-    }
 
     public override object Deserialize(TextReader tr) => null;
 
@@ -70,4 +56,4 @@ public int Compare(ICalendarProperty x, ICalendarProperty y)
                 : string.Compare(x.Name, y.Name, StringComparison.OrdinalIgnoreCase);
         }
     }
-}
\ No newline at end of file
+}


Reviews:
user: login: minichma
state: DISMISSED
author_association: COLLABORATOR
submitted_at: 2025-03-08T14:34:10Z
user: login: axunonb
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-09T09:03:44Z

Review comments:
diff_hunk: @@ -143,20 +147,54 @@ public override int GetHashCode()
     public virtual ICalendarObjectList<VTimeZone> TimeZones => _mTimeZones;
 
     /// <summary>
-    /// A collection of <see cref="Todo"/> components in the iCalendar.
+    /// A collection of <see cref="CalendarComponents.Todo"/> components in the iCalendar.
     /// </summary>
     public virtual IUniqueComponentList<Todo> Todos => _mTodos;
 
+    /// <summary>
+    /// Gets or sets the version of the iCalendar definition. The default is <see cref="LibraryMetadata.Version"/>
+    /// as per RFC 5545 Section 3.7.4 and must be specified.
+    /// <para/>
+    /// It specifies the identifier corresponding to the highest version number of the iCalendar specification
+    /// that is required in order to interpret the iCalendar object.
+    /// <para/>
+    /// <b>Do not change unless you are sure about the consequences.</b>
+    /// <para/>
+    /// The default value does not apply to deserialized objects.
+    /// </summary>
     public virtual string Version
     {
         get => Properties.Get<string>("VERSION");
-        set => Properties.Set("VERSION", value);
+        set
+        {
+            if (string.IsNullOrEmpty(value))
path: Ical.Net/Calendar.cs
user: login: minichma
body: Just a nit - not sure we need a special handling for null and empty here. Wouldn't know why for instance "XXX" would be any better than "". So for simplicity and to reduce the work f or CodeCov (aka the number of branches) I'd rather avoid such checks where not explicitly necessary.
created_at: 2025-03-08T14:31:34Z
author_association: COLLABORATOR
diff_hunk: @@ -143,20 +147,54 @@ public override int GetHashCode()
     public virtual ICalendarObjectList<VTimeZone> TimeZones => _mTimeZones;
 
     /// <summary>
-    /// A collection of <see cref="Todo"/> components in the iCalendar.
+    /// A collection of <see cref="CalendarComponents.Todo"/> components in the iCalendar.
     /// </summary>
     public virtual IUniqueComponentList<Todo> Todos => _mTodos;
 
+    /// <summary>
+    /// Gets or sets the version of the iCalendar definition. The default is <see cref="LibraryMetadata.Version"/>
+    /// as per RFC 5545 Section 3.7.4 and must be specified.
+    /// <para/>
+    /// It specifies the identifier corresponding to the highest version number of the iCalendar specification
+    /// that is required in order to interpret the iCalendar object.
+    /// <para/>
+    /// <b>Do not change unless you are sure about the consequences.</b>
+    /// <para/>
+    /// The default value does not apply to deserialized objects.
+    /// </summary>
     public virtual string Version
     {
         get => Properties.Get<string>("VERSION");
-        set => Properties.Set("VERSION", value);
+        set
+        {
+            if (string.IsNullOrEmpty(value))
+            {
+                throw new ArgumentException("Version to set must not be null or empty");
+            }
+            Properties.Set("VERSION", value);
+        }
     }
 
+    /// <summary>
+    /// Gets or sets the product ID of the iCalendar, which typically contains the name of the software
+    /// that created the iCalendar. The default is <see cref="LibraryMetadata.ProdId"/>.
+    /// <para/>
+    /// <b>Be careful when setting a custom value</b>, as it is free-form text that must conform to the iCalendar specification
+    /// (RFC 5545 Section 3.7.3). The product ID must be specified.
+    /// <para/>
+    /// The default value does not apply to deserialized objects.
+    /// </summary>
     public virtual string ProductId
     {
         get => Properties.Get<string>("PRODID");
-        set => Properties.Set("PRODID", value);
+        set
+        {
+            if (string.IsNullOrEmpty(value))
path: Ical.Net/Calendar.cs
user: login: minichma
body: Same as above - wouldn't see any requirement for special handling of null and empty.
created_at: 2025-03-08T14:32:34Z
author_association: COLLABORATOR
diff_hunk: @@ -143,20 +147,54 @@ public override int GetHashCode()
     public virtual ICalendarObjectList<VTimeZone> TimeZones => _mTimeZones;
 
     /// <summary>
-    /// A collection of <see cref="Todo"/> components in the iCalendar.
+    /// A collection of <see cref="CalendarComponents.Todo"/> components in the iCalendar.
     /// </summary>
     public virtual IUniqueComponentList<Todo> Todos => _mTodos;
 
+    /// <summary>
+    /// Gets or sets the version of the iCalendar definition. The default is <see cref="LibraryMetadata.Version"/>
+    /// as per RFC 5545 Section 3.7.4 and must be specified.
+    /// <para/>
+    /// It specifies the identifier corresponding to the highest version number of the iCalendar specification
+    /// that is required in order to interpret the iCalendar object.
+    /// <para/>
+    /// <b>Do not change unless you are sure about the consequences.</b>
+    /// <para/>
+    /// The default value does not apply to deserialized objects.
+    /// </summary>
     public virtual string Version
     {
         get => Properties.Get<string>("VERSION");
-        set => Properties.Set("VERSION", value);
+        set
+        {
+            if (string.IsNullOrEmpty(value))
path: Ical.Net/Calendar.cs
user: login: axunonb
body: The intention was to avoid users create invalid iCalendars. `VERSION` and `PRODID` must be set. But yes, xmldocs clearly say "know what you are doing". So I follow your comment regarding KISS.
created_at: 2025-03-09T09:03:44Z
author_association: COLLABORATOR

PR comments:
user: login: axunonb
created_at: 2025-03-06T08:53:03Z
body: @minichma TBD: Add the current ical.net version to the PRODID
author_association: COLLABORATOR
user: login: minichma
created_at: 2025-03-06T11:11:41Z
body: The RFC says the PRODID should contain

> Any text that describes the product and version

so, yes, including the version would probably be a good idea.
author_association: COLLABORATOR
user: login: axunonb
created_at: 2025-03-06T13:11:32Z
body: @minichma  RFC5545 says that PRODID and VERSION are mandatory. This may be the reason for the current implementation (always set the defaults when serializing).
But given this ics with missing PRODID and VERSION to deserialize:
```
BEGIN:VCALENDAR
BEGIN:VEVENT
DTSTART:20070406T230000Z
DTEND:20070407T010000Z
END:VEVENT
END:VCALENDAR
```
TBD:
Options
- Make it compliant by adding missing values: Then we don't see the original values.
- Leave it invalid. It's up to the user to fix, if needed before serializing (my preferred option, - breaking change to previous versions)
- Leave the properties unchanged (invalid), but replace empty PRODID or VERSION when serializing (non-braking, but kind of unexpected magic)
author_association: COLLABORATOR
user: login: minichma
created_at: 2025-03-06T15:24:43Z
body: > Leave it invalid. It's up to the user to fix, if needed before serializing (my preferred option, - breaking change to previous versions)

My preferred option too. Only populate ProdId and Version when creating a new calendar, but not when deserializing an existing one.
author_association: COLLABORATOR
user: login: sonarqubecloud[bot]
created_at: 2025-03-11T07:19:53Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=ical-org_ical.net&pullRequest=748) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=ical-org_ical.net&pullRequest=748&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=ical-org_ical.net&pullRequest=748&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=ical-org_ical.net&pullRequest=748&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=ical-org_ical.net&pullRequest=748&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=ical-org_ical.net&pullRequest=748&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=ical-org_ical.net&pullRequest=748)


author_association: NONE