|
| 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