-
Notifications
You must be signed in to change notification settings - Fork 0
/
PublishCommand.cs
251 lines (214 loc) · 9.3 KB
/
PublishCommand.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
// Copyright Bastian Eicher et al.
// Licensed under the GNU Lesser Public License
using NanoByte.Common;
using NanoByte.Common.Info;
using NanoByte.Common.Storage;
using NanoByte.Common.Tasks;
using NanoByte.Common.Undo;
using NDesk.Options;
using Spectre.Console;
using ZeroInstall.Model;
using ZeroInstall.Publish.Cli.Properties;
using ZeroInstall.Store.Configuration;
using ZeroInstall.Store.Trust;
namespace ZeroInstall.Publish.Cli;
/// <summary>
/// Creates or modified Zero Install feeds.
/// </summary>
public sealed class PublishCommand
{
private readonly ITaskHandler _handler;
/// <summary>The feeds to apply the operation on.</summary>
private ICollection<FileInfo> _feeds;
/// <summary>
/// Parses command-line arguments.
/// </summary>
/// <param name="args">The command-line arguments to be parsed.</param>
/// <param name="handler">A callback object used when the the user needs to be asked questions or informed about download and IO tasks.</param>
/// <exception cref="OperationCanceledException">The user asked to see help information, version information, etc..</exception>
/// <exception cref="OptionException"><paramref name="args"/> contains unknown options.</exception>
public PublishCommand(IEnumerable<string> args, ITaskHandler handler)
{
_handler = handler ?? throw new ArgumentNullException(nameof(handler));
var additionalArgs = BuildOptions().Parse(args ?? throw new ArgumentNullException(nameof(args)));
try
{
_feeds = Paths.ResolveFiles(additionalArgs, "*.xml");
}
#region Error handling
catch (FileNotFoundException ex)
{
// Report as an invalid command-line argument
throw new OptionException(ex.Message, ex.FileName);
}
#endregion
}
#region Options
/// <summary>The file to store the aggregated <see cref="Catalog"/> data in.</summary>
private string? _catalogFile;
/// <summary>Download missing archives, calculate manifest digests, etc..</summary>
private bool _addMissing;
/// <summary>Add XML signature blocks to the feed.</summary>
private bool _xmlSign;
/// <summary>Remove any existing signatures from the feeds.</summary>
private bool _unsign;
/// <summary>A key specifier (key ID, fingerprint or any part of a user ID) for the secret key to use to sign the feeds.</summary>
private string? _key;
/// <summary>The passphrase used to unlock the <see cref="OpenPgpSecretKey"/>.</summary>
private string? _openPgpPassphrase;
private OptionSet BuildOptions()
{
var options = new OptionSet
{
// Version information
{
"V|version", () => Resources.OptionVersion, _ =>
{
Console.WriteLine(@"0publish (.NET version) " + AppInfo.Current.Version + Environment.NewLine + AppInfo.Current.Copyright + Environment.NewLine + Resources.LicenseInfo);
throw new OperationCanceledException(); // Don't handle any of the other arguments
}
},
// Modes
{"catalog=", () => Resources.OptionCatalog, path => _catalogFile = Path.GetFullPath(path)},
{"add-missing", () => Resources.OptionAddMissing, _ => _addMissing = true},
// Signatures
{"x|xmlsign", () => Resources.OptionXmlSign, _ => _xmlSign = true},
{"u|unsign", () => Resources.OptionUnsign, _ => _unsign = true},
{"k|key=", () => Resources.OptionKey, user => _key = user},
{"gpg-passphrase=", () => Resources.OptionGnuPGPassphrase, passphrase => _openPgpPassphrase = passphrase}
};
options.Add("h|help|?", () => Resources.OptionHelp, _ =>
{
Console.WriteLine(Resources.Usage);
// ReSharper disable LocalizableElement
Console.WriteLine("\t0publish [OPTIONS] FEED-FILE");
Console.WriteLine("\t0publish capture --help");
Console.WriteLine("\t0publish bootstrap --help");
// ReSharper restore LocalizableElement
Console.WriteLine();
Console.WriteLine(Resources.Options);
options.WriteOptionDescriptions(Console.Out);
// Don't handle any of the other arguments
throw new OperationCanceledException();
});
return options;
}
#endregion
/// <summary>
/// Executes the commands specified by the command-line arguments.
/// </summary>
public void Execute()
{
if (!string.IsNullOrEmpty(_catalogFile))
{
// Default to using all XML files in the current directory
if (_feeds.Count == 0)
_feeds = Paths.ResolveFiles([Environment.CurrentDirectory], "*.xml");
GenerateCatalog();
return;
}
if (_feeds.Count == 0)
{
throw new OptionException(string.Format(Resources.MissingArguments, "0publish --help"), "");
}
foreach (var file in _feeds)
{
var feedEditing = FeedEditing.Load(file.FullName);
var feed = feedEditing.SignedFeed.Feed;
feed.ResolveInternalReferences();
if (_addMissing) AddMissing(feed.Implementations, feedEditing);
SaveFeed(feedEditing);
}
}
private void GenerateCatalog()
{
var catalog = new Catalog();
foreach (var feed in _feeds.Select(feedFile => XmlStorage.LoadXml<Feed>(feedFile.FullName)))
{
feed.Strip();
catalog.Feeds.Add(feed);
}
if (catalog.Feeds.Count == 0) throw new FileNotFoundException(Resources.NoFeedFilesFound);
if (_xmlSign)
{
var openPgp = OpenPgp.Signing();
var signedCatalog = new SignedCatalog(catalog, openPgp.GetSecretKey(_key));
PromptPassphrase(
() => signedCatalog.Save(_catalogFile!, _openPgpPassphrase),
signedCatalog.SecretKey);
}
else catalog.SaveXml(_catalogFile!);
}
private void AddMissing(IEnumerable<Implementation> implementations, ICommandExecutor executor)
{
executor = new ConcurrentCommandExecutor(executor);
try
{
implementations.AsParallel()
.WithDegreeOfParallelism(Config.LoadSafe().MaxParallelDownloads)
.ForAll(implementation => implementation.SetMissing(executor, _handler));
}
catch (AggregateException ex)
{
throw ex.RethrowFirstInner();
}
}
private void SaveFeed(FeedEditing feedEditing)
{
if (!feedEditing.Path!.EndsWith(".xml.template")
&& !feedEditing.IsValid(out string problem))
Log.Warn(problem);
if (_unsign)
{
// Remove any existing signatures
feedEditing.SignedFeed.SecretKey = null;
}
else
{
var openPgp = OpenPgp.Signing();
if (_xmlSign)
{ // Signing explicitly requested
if (feedEditing.SignedFeed.SecretKey == null)
{ // No previous signature
// Use user-specified key or default key
feedEditing.SignedFeed.SecretKey = openPgp.GetSecretKey(_key);
}
else
{ // Existing signature
if (!string.IsNullOrEmpty(_key)) // Use new user-specified key
feedEditing.SignedFeed.SecretKey = openPgp.GetSecretKey(_key);
//else resign implied
}
}
//else resign implied
}
// If no signing or unsigning was explicitly requested and the content did not change
// there is no need to overwrite (and potential resign) the file
if (!_xmlSign && !_unsign && !feedEditing.UnsavedChanges) return;
PromptPassphrase(
() => feedEditing.SignedFeed.Save(feedEditing.Path!, _openPgpPassphrase),
feedEditing.SignedFeed.SecretKey);
}
/// <summary>
/// Runs the specified <paramref name="action"/> and prompts for the <paramref name="secretKey"/> if <see cref="WrongPassphraseException"/> is thrown.
/// </summary>
/// <exception cref="OperationCanceledException">The user cancelled the passphrase entry.</exception>
private void PromptPassphrase(Action action, OpenPgpSecretKey? secretKey)
{
while (true)
{
try
{
action();
return; // Exit loop if passphrase is correct
}
catch (WrongPassphraseException ex) when (secretKey != null)
{
// Only print error if a passphrase was actually entered
if (_openPgpPassphrase != null) Log.Error(ex);
// Ask for passphrase to unlock secret key if we were unable to save without it
_openPgpPassphrase = AnsiCli.Prompt(new TextPrompt<string>(string.Format(Resources.AskForPassphrase, secretKey.UserID)).Secret(), _handler.CancellationToken);
}
}
}
}