Skip to content

Commit e25fcfa

Browse files
committed
Update doc for registries + Move generate_registry to tools
1 parent be3714d commit e25fcfa

2 files changed

Lines changed: 348 additions & 1 deletion

File tree

tools/Documentation/advanced-usage/plugin-registries.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,15 @@ A registry must have the following file structure:
4444
| |- ...
4545
```
4646

47-
The `registry.json` holds info about all plugins contained in a registry (you can also generate it with [generate_registry.pl](https://github.com/psilabs-dev/lrr-plugins-demo/blob/main/generate_registry.pl)):
47+
The `registry.json` holds info about all plugins contained in a registry.
48+
You can write it by hand, or generate it with [generate_registry.pl](https://github.com/difegue/lanraragi/blob/dev/tools/generate_registry.pl)):
4849

50+
```
51+
cpanm JSON::PP
52+
perl generate_registry.pl [folder of your registry]
53+
```
54+
55+
The JSON should look like the following:
4956
```json
5057
{
5158
"generated_at": "...",

tools/generate_registry.pl

Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,340 @@
1+
#!/usr/bin/env perl
2+
3+
use strict;
4+
use warnings;
5+
6+
use Cwd qw(abs_path);
7+
use Digest::SHA qw(sha256_hex);
8+
use File::Basename qw(dirname);
9+
use File::Spec;
10+
use JSON::PP qw(decode_json);
11+
use POSIX qw(strftime);
12+
13+
use feature qw(say);
14+
15+
use constant MAX_ARTIFACT_SIZE => 1024 * 1024 * 1024;
16+
17+
say("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~");
18+
say("~~~~~LANraragi Plugin Registry Generator~~~~~");
19+
say("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~");
20+
21+
my $working_dir = $ARGV[0] // $0; # Use the first argument as working directory, or the script path if not provided
22+
my $script_dir = dirname(abs_path($working_dir));
23+
my $artifact_root = "$script_dir/artifacts";
24+
my $output_file = "$script_dir/registry.json";
25+
my $now = strftime("%Y-%m-%dT%H:%M:%SZ", gmtime);
26+
27+
die "Artifact directory not found in $working_dir: $artifact_root\n" unless -d $artifact_root;
28+
unless (-f $output_file) {
29+
# Create an empty manifest if it doesn't exist
30+
write_manifest(
31+
$output_file,
32+
{
33+
version => 1,
34+
generated_at => $now,
35+
plugins => {}
36+
}
37+
);
38+
say "Created empty manifest at $output_file.";
39+
}
40+
41+
my @PLUGIN_INFO_FIELDS = qw(name type namespace author version description);
42+
my %VALID_TYPES = map { $_ => 1 } qw(metadata download login script);
43+
my %RESERVED_PARAM_KEY = map { $_ => 1 } qw(
44+
installed_path installed_version installed_registry installed_sha256 type
45+
enabled hidden priority
46+
customargs
47+
);
48+
49+
my $existing_manifest = decode_json_file($output_file);
50+
my $existing_plugins = load_existing_plugins($existing_manifest);
51+
my %plugins;
52+
53+
say "Generating registry from plugins detected in $artifact_root...";
54+
55+
for my $namespace (list_child_directories($artifact_root)) {
56+
my $namespace_dir = File::Spec->catdir($artifact_root, $namespace);
57+
58+
die "Manifest is missing plugin record for namespace '$namespace'\n"
59+
unless exists $existing_plugins->{$namespace};
60+
61+
my $existing_plugin = $existing_plugins->{$namespace};
62+
63+
$plugins{$namespace} = {
64+
namespace => $existing_plugin->{namespace},
65+
type => $existing_plugin->{type},
66+
versions => {},
67+
};
68+
69+
for my $version (list_child_directories($namespace_dir)) {
70+
my $version_dir = File::Spec->catdir($namespace_dir, $version);
71+
my @artifacts = grep { /\.pm\z/ } list_child_files($version_dir);
72+
73+
die "Expected exactly one plugin artifact in $version_dir\n"
74+
unless @artifacts == 1;
75+
76+
my $filename = $artifacts[0];
77+
my $full_path = File::Spec->catfile($version_dir, $filename);
78+
my $canonical_path = validate_artifact_path($full_path);
79+
my $rel_path = rel_to_root($full_path);
80+
my $artifact_content = read_file_raw($canonical_path);
81+
my $artifact_sha256 = sha256_hex($artifact_content);
82+
my $info = parse_plugin_artifact_content( $canonical_path, $artifact_content );
83+
validate_plugin_parameters( $canonical_path, $artifact_content );
84+
85+
die "Namespace directory mismatch for $rel_path\n"
86+
unless $info->{namespace} eq $namespace;
87+
die "Version directory mismatch for $rel_path\n"
88+
unless $info->{version} eq $version;
89+
die "Unsupported plugin type '$info->{type}' in $rel_path\n"
90+
unless $VALID_TYPES{ $info->{type} };
91+
die "Manifest type mismatch for namespace '$namespace'\n"
92+
unless $existing_plugin->{type} eq $info->{type};
93+
94+
my $existing_record = $existing_plugin->{versions}{$version};
95+
if ($existing_record) {
96+
die "Published artifact bytes changed for existing $namespace/$version\n"
97+
unless $existing_record->{sha256} eq $artifact_sha256;
98+
}
99+
100+
$plugins{$namespace}{versions}{$version} = {
101+
version => $info->{version},
102+
name => $info->{name},
103+
author => $info->{author},
104+
description => $info->{description},
105+
artifact => $rel_path,
106+
sha256 => $artifact_sha256,
107+
published_at => $existing_record ? $existing_record->{published_at} : $now,
108+
};
109+
110+
say "Detected plugin: $namespace/$version ($rel_path)";
111+
}
112+
113+
die "No published versions found for namespace '$namespace'\n"
114+
unless keys %{ $plugins{$namespace}{versions} };
115+
}
116+
117+
for my $namespace ( sort keys %{$existing_plugins} ) {
118+
die "Manifest references namespace '$namespace' with no artifact directory\n"
119+
unless exists $plugins{$namespace};
120+
}
121+
122+
write_manifest(
123+
$output_file,
124+
{
125+
version => 1,
126+
generated_at => $now,
127+
plugins => \%plugins,
128+
}
129+
);
130+
131+
say "Wrote $output_file (" . scalar( keys %plugins ) . " plugins)";
132+
133+
sub decode_json_file {
134+
my ($path) = @_;
135+
return decode_json( read_file_utf8($path) );
136+
}
137+
138+
sub load_existing_plugins {
139+
my ($manifest) = @_;
140+
141+
die "Manifest version must be 1\n"
142+
unless defined $manifest->{version} && $manifest->{version} == 1;
143+
die "Manifest generated_at must be a string\n"
144+
unless defined $manifest->{generated_at} && !ref $manifest->{generated_at};
145+
die "Manifest plugins must be an object\n"
146+
unless ref $manifest->{plugins} eq 'HASH';
147+
148+
my %plugins;
149+
150+
for my $namespace ( sort keys %{ $manifest->{plugins} } ) {
151+
my $plugin = $manifest->{plugins}{$namespace};
152+
153+
die "Plugin record for '$namespace' must be an object\n"
154+
unless ref $plugin eq 'HASH';
155+
die "Plugin key '$namespace' must match inner namespace\n"
156+
unless defined $plugin->{namespace} && $plugin->{namespace} eq $namespace;
157+
die "Plugin '$namespace' is missing type\n"
158+
unless defined $plugin->{type};
159+
die "Plugin '$namespace' has invalid type '$plugin->{type}'\n"
160+
unless $VALID_TYPES{ $plugin->{type} };
161+
die "Plugin '$namespace' versions must be an object\n"
162+
unless ref $plugin->{versions} eq 'HASH';
163+
die "Plugin '$namespace' versions must be non-empty\n"
164+
unless keys %{ $plugin->{versions} };
165+
166+
$plugins{$namespace} = {
167+
namespace => $plugin->{namespace},
168+
type => $plugin->{type},
169+
versions => {},
170+
};
171+
172+
for my $version ( sort keys %{ $plugin->{versions} } ) {
173+
my $record = $plugin->{versions}{$version};
174+
175+
die "Version record for '$namespace/$version' must be an object\n"
176+
unless ref $record eq 'HASH';
177+
die "Version key '$namespace/$version' must match inner version\n"
178+
unless defined $record->{version} && $record->{version} eq $version;
179+
die "Version record for '$namespace/$version' is missing published_at\n"
180+
unless defined $record->{published_at};
181+
die "Version record for '$namespace/$version' is missing sha256\n"
182+
unless defined $record->{sha256};
183+
184+
$plugins{$namespace}{versions}{$version} = {
185+
published_at => $record->{published_at},
186+
sha256 => $record->{sha256},
187+
};
188+
}
189+
}
190+
191+
return \%plugins;
192+
}
193+
194+
sub parse_plugin_artifact_content {
195+
my ( $path, $content ) = @_;
196+
my ($info_body) = $content =~ /sub\s+plugin_info\s*\{(.*?)^\}/ms;
197+
die "No plugin_info found in $path\n" unless defined $info_body;
198+
199+
my %info;
200+
for my $field (@PLUGIN_INFO_FIELDS) {
201+
if ( $info_body =~ /\b$field\s*=>\s*"((?:[^"\\]|\\.)*)"/s ) {
202+
$info{$field} = $1;
203+
} elsif ( $info_body =~ /\b$field\s*=>\s*'((?:[^'\\]|\\.)*)'/s ) {
204+
$info{$field} = $1;
205+
} else {
206+
die "Missing '$field' in plugin_info for $path\n";
207+
}
208+
}
209+
210+
$info{description} =~ s/\s*\n\s*/ /g;
211+
return \%info;
212+
}
213+
214+
sub validate_plugin_parameters {
215+
my ( $path, $content ) = @_;
216+
217+
my ($info_body) = $content =~ /sub\s+plugin_info\s*\{(.*?)^\}/ms;
218+
return unless defined $info_body;
219+
220+
if ( $info_body =~ /\bparameters\s*=>\s*\[/ ) {
221+
die "Array-style 'parameters' is not supported; use hash-style: $path\n";
222+
}
223+
224+
if ( $info_body =~ /\bparameters\s*=>\s*(\{(?:(?>[^{}]+)|(?1))*\})/x ) {
225+
my $body = $1;
226+
$body =~ s/\A\{//;
227+
$body =~ s/\}\z//;
228+
229+
# Strip string contents to avoid false 'key =>' matches inside literals.
230+
$body =~ s/"((?:[^"\\]|\\.)*)"/""/gs;
231+
$body =~ s/'((?:[^'\\]|\\.)*)'/''/gs;
232+
233+
for my $key ( top_level_param_keys($body) ) {
234+
die "Parameter key '$key' is reserved by LANraragi internal state: $path\n"
235+
if $RESERVED_PARAM_KEY{$key};
236+
}
237+
}
238+
}
239+
240+
sub top_level_param_keys {
241+
my ($body) = @_;
242+
my @keys;
243+
my $depth = 0;
244+
my $i = 0;
245+
my $len = length $body;
246+
while ( $i < $len ) {
247+
my $c = substr( $body, $i, 1 );
248+
if ( $c eq '{' ) { $depth++; $i++; }
249+
elsif ( $c eq '}' ) { $depth--; $i++; }
250+
elsif ( $depth == 0 && $c =~ /[A-Za-z_]/ ) {
251+
if ( substr( $body, $i ) =~ /^([A-Za-z_][A-Za-z0-9_-]*)\s*=>/ ) {
252+
push @keys, $1;
253+
$i += length $1;
254+
} else {
255+
$i++;
256+
}
257+
} else {
258+
$i++;
259+
}
260+
}
261+
return @keys;
262+
}
263+
264+
sub validate_artifact_path {
265+
my ($path) = @_;
266+
267+
my $canonical_path = abs_path($path);
268+
die "Artifact path cannot be canonicalized: $path\n" unless defined $canonical_path;
269+
270+
my $registry_root = abs_path($script_dir);
271+
die "Registry root cannot be canonicalized: $script_dir\n" unless defined $registry_root;
272+
273+
die "Artifact escapes registry root after canonicalization: $path\n"
274+
unless index( $canonical_path, "$registry_root/" ) == 0;
275+
276+
die "Artifact is not a regular file after canonicalization: $path\n"
277+
unless -f $canonical_path;
278+
279+
my $size = -s $canonical_path;
280+
die "Artifact size is unavailable after canonicalization: $path\n"
281+
unless defined $size;
282+
die "Artifact exceeds MAX_ARTIFACT_SIZE (" . MAX_ARTIFACT_SIZE . " bytes): $path\n"
283+
if $size > MAX_ARTIFACT_SIZE;
284+
285+
return $canonical_path;
286+
}
287+
288+
sub list_child_directories {
289+
my ($dir) = @_;
290+
opendir( my $dh, $dir ) or die "Cannot open $dir: $!\n";
291+
my @entries = sort grep {
292+
$_ ne '.'
293+
&& $_ ne '..'
294+
&& -d File::Spec->catdir( $dir, $_ )
295+
} readdir($dh);
296+
closedir $dh or die "Cannot close $dir: $!\n";
297+
return @entries;
298+
}
299+
300+
sub list_child_files {
301+
my ($dir) = @_;
302+
opendir( my $dh, $dir ) or die "Cannot open $dir: $!\n";
303+
my @entries = sort grep {
304+
$_ ne '.'
305+
&& $_ ne '..'
306+
&& -f File::Spec->catfile( $dir, $_ )
307+
} readdir($dh);
308+
closedir $dh or die "Cannot close $dir: $!\n";
309+
return @entries;
310+
}
311+
312+
sub rel_to_root {
313+
my ($path) = @_;
314+
my $rel = $path;
315+
$rel =~ s/^\Q$script_dir\E\/?//;
316+
return $rel;
317+
}
318+
319+
sub read_file_raw {
320+
my ($path) = @_;
321+
open( my $fh, '<:raw', $path ) or die "Cannot read $path: $!\n";
322+
my $content = do { local $/; <$fh> };
323+
close $fh or die "Cannot close $path: $!\n";
324+
return $content;
325+
}
326+
327+
sub read_file_utf8 {
328+
my ($path) = @_;
329+
open( my $fh, '<:utf8', $path ) or die "Cannot read $path: $!\n";
330+
my $content = do { local $/; <$fh> };
331+
close $fh or die "Cannot close $path: $!\n";
332+
return $content;
333+
}
334+
335+
sub write_manifest {
336+
my ( $path, $data ) = @_;
337+
open( my $fh, '>:utf8', $path ) or die "Cannot write $path: $!\n";
338+
print {$fh} JSON::PP->new->canonical->pretty->encode($data);
339+
close $fh or die "Cannot close $path: $!\n";
340+
}

0 commit comments

Comments
 (0)