Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • project/automatic_updates
  • issue/automatic_updates-3159719
  • issue/automatic_updates-3227923
  • issue/automatic_updates-3227927
  • issue/automatic_updates-3227122
  • issue/automatic_updates-3227940
  • issue/automatic_updates-3228095
  • issue/automatic_updates-3228101
  • issue/automatic_updates-3228115
  • issue/automatic_updates-3228125
  • issue/automatic_updates-3228359
  • issue/automatic_updates-3228392
  • issue/automatic_updates-3225753
  • issue/automatic_updates-3227588
  • issue/automatic_updates-3228521
  • issue/automatic_updates-3228539
  • issue/automatic_updates-3226570
  • issue/automatic_updates-3228806
  • issue/automatic_updates-3227575
  • issue/automatic_updates-3229485
  • issue/automatic_updates-3228955
  • issue/automatic_updates-3230024
  • issue/automatic_updates-3230250
  • issue/automatic_updates-3230249
  • issue/automatic_updates-3230264
  • issue/automatic_updates-3230510
  • issue/automatic_updates-3230235
  • issue/automatic_updates-3230050
  • issue/automatic_updates-3230934
  • issue/automatic_updates-3230049
  • issue/automatic_updates-3230507
  • issue/automatic_updates-3231153
  • issue/automatic_updates-3231992
  • issue/automatic_updates-3232003
  • issue/automatic_updates-3232004
  • issue/automatic_updates-3231996
  • issue/automatic_updates-3231999
  • issue/automatic_updates-3230668
  • issue/automatic_updates-3232000
  • issue/automatic_updates-3232420
  • issue/automatic_updates-3232729
  • issue/automatic_updates-3232761
  • issue/automatic_updates-3231997
  • issue/automatic_updates-3232927
  • issue/automatic_updates-3232959
  • issue/automatic_updates-3233124
  • issue/automatic_updates-3230856
  • issue/automatic_updates-3233493
  • issue/automatic_updates-3233587
  • issue/automatic_updates-3233521
  • issue/automatic_updates-3236405
  • issue/automatic_updates-3236299
  • issue/automatic_updates-3238586
  • issue/automatic_updates-3238647
  • issue/automatic_updates-3238717
  • issue/automatic_updates-3238846
  • issue/automatic_updates-3238852
  • issue/automatic_updates-3238866
  • issue/automatic_updates-3238714
  • issue/automatic_updates-3239103
  • issue/automatic_updates-3233138
  • issue/automatic_updates-3239466
  • issue/automatic_updates-3239645
  • issue/automatic_updates-3239673
  • issue/automatic_updates-3277775
  • issue/automatic_updates-3280923
  • issue/automatic_updates-3275315
  • issue/automatic_updates-3279527
  • issue/automatic_updates-3279733
  • issue/automatic_updates-3281405
  • issue/automatic_updates-3281397
  • issue/automatic_updates-3280204
  • issue/automatic_updates-3277817
  • issue/automatic_updates-3277230
  • issue/automatic_updates-3276534
  • issue/automatic_updates-3279538
  • issue/automatic_updates-3280168
  • issue/automatic_updates-3271502
  • issue/automatic_updates-3283644
  • issue/automatic_updates-3302524
  • issue/automatic_updates-3275883
  • issue/automatic_updates-3280180
  • issue/automatic_updates-3274837
  • issue/automatic_updates-3281473
  • issue/automatic_updates-3281634
  • issue/automatic_updates-3239889
  • issue/automatic_updates-3240753
  • issue/automatic_updates-3233103
  • issue/automatic_updates-3240971
  • issue/automatic_updates-3233564
  • issue/automatic_updates-3241380
  • issue/automatic_updates-3241105
  • issue/automatic_updates-3242626
  • issue/automatic_updates-3242724
  • issue/automatic_updates-3243057
  • issue/automatic_updates-3243348
  • issue/automatic_updates-3243339
  • issue/automatic_updates-3243422
  • issue/automatic_updates-3243436
  • issue/automatic_updates-3243600
  • issue/automatic_updates-3243851
  • issue/automatic_updates-3243405
  • issue/automatic_updates-3244338
  • issue/automatic_updates-3244337
  • issue/automatic_updates-3244360
  • issue/automatic_updates-3244358
  • issue/automatic_updates-3244679
  • issue/automatic_updates-3244939
  • issue/automatic_updates-3244976
  • issue/automatic_updates-3245376
  • issue/automatic_updates-3245388
  • issue/automatic_updates-3245428
  • issue/automatic_updates-3245655
  • issue/automatic_updates-3245766
  • issue/automatic_updates-3245810
  • issue/automatic_updates-3245846
  • issue/automatic_updates-3246036
  • issue/automatic_updates-3246194
  • issue/automatic_updates-3246203
  • issue/automatic_updates-3246283
  • issue/automatic_updates-3279064
  • issue/automatic_updates-3246420
  • issue/automatic_updates-3246638
  • issue/automatic_updates-3246660
  • issue/automatic_updates-3246673
  • issue/automatic_updates-3246695
  • issue/automatic_updates-3245996
  • issue/automatic_updates-3247308
  • issue/automatic_updates-3247478
  • issue/automatic_updates-3247479
  • issue/automatic_updates-3279086
  • issue/automatic_updates-3248312
  • issue/automatic_updates-3248523
  • issue/automatic_updates-3248527
  • issue/automatic_updates-3244412
  • issue/automatic_updates-3248976
  • issue/automatic_updates-3248909
  • issue/automatic_updates-3246507
  • issue/automatic_updates-3249531
  • issue/automatic_updates-3249517
  • issue/automatic_updates-3249983
  • issue/automatic_updates-3249135
  • issue/automatic_updates-3250136
  • issue/automatic_updates-3250386
  • issue/automatic_updates-3251701
  • issue/automatic_updates-3250696
  • issue/automatic_updates-3248911
  • issue/automatic_updates-3227420
  • issue/automatic_updates-3252071
  • issue/automatic_updates-3252097
  • issue/automatic_updates-3252109
  • issue/automatic_updates-3252126
  • issue/automatic_updates-3249130
  • issue/automatic_updates-3251972
  • issue/automatic_updates-3252187
  • issue/automatic_updates-3252328
  • issue/automatic_updates-3252324
  • issue/automatic_updates-3252533
  • issue/automatic_updates-3253055
  • issue/automatic_updates-3253186
  • issue/automatic_updates-3253395
  • issue/automatic_updates-3253400
  • issue/automatic_updates-3253624
  • issue/automatic_updates-3253647
  • issue/automatic_updates-3253649
  • issue/automatic_updates-3253858
  • issue/automatic_updates-3254166
  • issue/automatic_updates-3254207
  • issue/automatic_updates-3279108
  • issue/automatic_updates-3254606
  • issue/automatic_updates-3254616
  • issue/automatic_updates-3254911
  • issue/automatic_updates-3255011
  • issue/automatic_updates-3255014
  • issue/automatic_updates-3255320
  • issue/automatic_updates-3277562
  • issue/automatic_updates-3255678
  • issue/automatic_updates-3256958
  • issue/automatic_updates-3257115
  • issue/automatic_updates-3257432
  • issue/automatic_updates-3257446
  • issue/automatic_updates-3257473
  • issue/automatic_updates-3257134
  • issue/automatic_updates-3257849
  • issue/automatic_updates-3258065
  • issue/automatic_updates-3258056
  • issue/automatic_updates-3258048
  • issue/automatic_updates-3258045
  • issue/automatic_updates-3258464
  • issue/automatic_updates-3258611
  • issue/automatic_updates-3258646
  • issue/automatic_updates-3258661
  • issue/automatic_updates-3258667
  • issue/automatic_updates-3258590
  • issue/automatic_updates-3259228
  • issue/automatic_updates-3259656
  • issue/automatic_updates-3252299
  • issue/automatic_updates-3259664
  • issue/automatic_updates-3259822
  • issue/automatic_updates-3259810
  • issue/automatic_updates-3259814
  • issue/automatic_updates-3260368
  • issue/automatic_updates-3257845
  • issue/automatic_updates-3260664
  • issue/automatic_updates-3260672
  • issue/automatic_updates-3260698
  • issue/automatic_updates-3260662
  • issue/automatic_updates-3260669
  • issue/automatic_updates-3260668
  • issue/automatic_updates-3261637
  • issue/automatic_updates-3261772
  • issue/automatic_updates-3261847
  • issue/automatic_updates-3262016
  • issue/automatic_updates-3262244
  • issue/automatic_updates-3262303
  • issue/automatic_updates-3261642
  • issue/automatic_updates-3262359
  • issue/automatic_updates-3262542
  • issue/automatic_updates-3261098
  • issue/automatic_updates-3260666
  • issue/automatic_updates-3262587
  • issue/automatic_updates-3262965
  • issue/automatic_updates-3263155
  • issue/automatic_updates-3263223
  • issue/automatic_updates-3254755
  • issue/automatic_updates-3261758
  • issue/automatic_updates-3258059
  • issue/automatic_updates-3259196
  • issue/automatic_updates-3263865
  • issue/automatic_updates-3262284
  • issue/automatic_updates-3264554
  • issue/automatic_updates-3248928
  • issue/automatic_updates-3248929
  • issue/automatic_updates-3265072
  • issue/automatic_updates-3265057
  • issue/automatic_updates-3265873
  • issue/automatic_updates-3266092
  • issue/automatic_updates-3266633
  • issue/automatic_updates-3266640
  • issue/automatic_updates-3266687
  • issue/automatic_updates-3266981
  • issue/automatic_updates-3267387
  • issue/automatic_updates-3267389
  • issue/automatic_updates-3267411
  • issue/automatic_updates-3267603
  • issue/automatic_updates-3265874
  • issue/automatic_updates-3267577
  • issue/automatic_updates-3267386
  • issue/automatic_updates-3268363
  • issue/automatic_updates-3268868
  • issue/automatic_updates-3268612
  • issue/automatic_updates-3269097
  • issue/automatic_updates-3267632
  • issue/automatic_updates-3270736
  • issue/automatic_updates-3271144
  • issue/automatic_updates-3271226
  • issue/automatic_updates-3271078
  • issue/automatic_updates-3271371
  • issue/automatic_updates-3267646
  • issue/automatic_updates-3272060
  • issue/automatic_updates-3265031
  • issue/automatic_updates-3253591
  • issue/automatic_updates-3272520
  • issue/automatic_updates-3272061
  • issue/automatic_updates-3272785
  • issue/automatic_updates-3273011
  • issue/automatic_updates-3273008
  • issue/automatic_updates-3273364
  • issue/automatic_updates-3273407
  • issue/automatic_updates-3271235
  • issue/automatic_updates-3273006
  • issue/automatic_updates-3271240
  • issue/automatic_updates-3271468
  • issue/automatic_updates-3272326
  • issue/automatic_updates-3273693
  • issue/automatic_updates-3264849
  • issue/automatic_updates-3273807
  • issue/automatic_updates-3273017
  • issue/automatic_updates-3274269
  • issue/automatic_updates-3274273
  • issue/automatic_updates-3274292
  • issue/automatic_updates-3273812
  • issue/automatic_updates-3274323
  • issue/automatic_updates-3274633
  • issue/automatic_updates-3274858
  • issue/automatic_updates-3274830
  • issue/automatic_updates-3274892
  • issue/automatic_updates-3275075
  • issue/automatic_updates-3275256
  • issue/automatic_updates-3275282
  • issue/automatic_updates-3275320
  • issue/automatic_updates-3275323
  • issue/automatic_updates-3275324
  • issue/automatic_updates-3275317
  • issue/automatic_updates-3275313
  • issue/automatic_updates-3275357
  • issue/automatic_updates-3275474
  • issue/automatic_updates-3275311
  • issue/automatic_updates-3275546
  • issue/automatic_updates-3275508
  • issue/automatic_updates-3275810
  • issue/automatic_updates-3275860
  • issue/automatic_updates-3275865
  • issue/automatic_updates-3275886
  • issue/automatic_updates-3275887
  • issue/automatic_updates-3276031
  • issue/automatic_updates-3276041
  • issue/automatic_updates-3276072
  • issue/automatic_updates-3276159
  • issue/automatic_updates-3276255
  • issue/automatic_updates-3275825
  • issue/automatic_updates-3276661
  • issue/automatic_updates-3275369
  • issue/automatic_updates-3277014
  • issue/automatic_updates-3277035
  • issue/automatic_updates-3272813
  • issue/automatic_updates-3277211
  • issue/automatic_updates-3277229
  • issue/automatic_updates-3275316
  • issue/automatic_updates-3274047
  • issue/automatic_updates-3277815
  • issue/automatic_updates-3276662
  • issue/automatic_updates-3277235
  • issue/automatic_updates-3278411
  • issue/automatic_updates-3278435
  • issue/automatic_updates-3279229
  • issue/automatic_updates-3277007
  • issue/automatic_updates-3284346
  • issue/automatic_updates-3239759
  • issue/automatic_updates-3284495
  • issue/automatic_updates-3284530
  • issue/automatic_updates-3282677
  • issue/automatic_updates-3281379
  • issue/automatic_updates-3285408
  • issue/automatic_updates-3285631
  • issue/automatic_updates-3285669
  • issue/automatic_updates-3273014
  • issue/automatic_updates-3273369
  • issue/automatic_updates-3285491
  • issue/automatic_updates-3285898
  • issue/automatic_updates-3287251
  • issue/automatic_updates-3291147
  • issue/automatic_updates-3286650
  • issue/automatic_updates-3291770
  • issue/automatic_updates-3280403
  • issue/automatic_updates-3291959
  • issue/automatic_updates-3287398
  • issue/automatic_updates-3285145
  • issue/automatic_updates-3292027
  • issue/automatic_updates-3278445
  • issue/automatic_updates-3292933
  • issue/automatic_updates-3292958
  • issue/automatic_updates-3293157
  • issue/automatic_updates-3292956
  • issue/automatic_updates-3293146
  • issue/automatic_updates-3293381
  • issue/automatic_updates-3293449
  • issue/automatic_updates-3293427
  • issue/automatic_updates-3293422
  • issue/automatic_updates-3293685
  • issue/automatic_updates-3291730
  • issue/automatic_updates-3293866
  • issue/automatic_updates-3294338
  • issue/automatic_updates-3293656
  • issue/automatic_updates-3294600
  • issue/automatic_updates-3294335
  • issue/automatic_updates-3295013
  • issue/automatic_updates-3284936
  • issue/automatic_updates-3295596
  • issue/automatic_updates-3295758
  • issue/automatic_updates-3295791
  • issue/automatic_updates-3295830
  • issue/automatic_updates-3295874
  • issue/automatic_updates-3295897
  • issue/automatic_updates-3296074
  • issue/automatic_updates-3295965
  • issue/automatic_updates-3296181
  • issue/automatic_updates-3296065
  • issue/automatic_updates-3296178
  • issue/automatic_updates-3296261
  • issue/automatic_updates-3293148
  • issue/automatic_updates-3303953
  • issue/automatic_updates-3304367
  • issue/automatic_updates-3304640
  • issue/automatic_updates-3304836
  • issue/automatic_updates-3304142
  • issue/automatic_updates-3298349
  • issue/automatic_updates-3298444
  • issue/automatic_updates-3298510
  • issue/automatic_updates-3298431
  • issue/automatic_updates-3298863
  • issue/automatic_updates-3298877
  • issue/automatic_updates-3298904
  • issue/automatic_updates-3298951
  • issue/automatic_updates-3299101
  • issue/automatic_updates-3299417
  • issue/automatic_updates-3293417
  • issue/automatic_updates-3299612
  • issue/automatic_updates-3299093
  • issue/automatic_updates-3299087
  • issue/automatic_updates-3300006
  • issue/automatic_updates-3300036
  • issue/automatic_updates-3293150
  • issue/automatic_updates-3303807
  • issue/automatic_updates-3303929
  • issue/automatic_updates-3304417
  • issue/automatic_updates-3303727
  • issue/automatic_updates-3305527
  • issue/automatic_updates-3301844
  • issue/automatic_updates-3302527
  • issue/automatic_updates-3302673
  • issue/automatic_updates-3302897
  • issue/automatic_updates-3303143
  • issue/automatic_updates-3303168
  • issue/automatic_updates-3303174
  • issue/automatic_updates-3303185
  • issue/automatic_updates-3303200
  • issue/automatic_updates-3303113
  • issue/automatic_updates-3304036
  • issue/automatic_updates-3304165
  • issue/automatic_updates-3304583
  • issue/automatic_updates-3303902
  • issue/automatic_updates-3304651
  • issue/automatic_updates-3305312
  • issue/automatic_updates-3305612
  • issue/automatic_updates-3305568
  • issue/automatic_updates-3305773
  • issue/automatic_updates-3305874
  • issue/automatic_updates-3310901
  • issue/automatic_updates-3310929
  • issue/automatic_updates-3310936
  • issue/automatic_updates-3310972
  • issue/automatic_updates-3311001
  • issue/automatic_updates-3311020
  • issue/automatic_updates-3305240
  • issue/automatic_updates-3305994
  • issue/automatic_updates-3305564
  • issue/automatic_updates-3305998
  • issue/automatic_updates-3306600
  • issue/automatic_updates-3306631
  • issue/automatic_updates-3307086
  • issue/automatic_updates-3307103
  • issue/automatic_updates-3307168
  • issue/automatic_updates-3306163
  • issue/automatic_updates-3304617
  • issue/automatic_updates-3307163
  • issue/automatic_updates-3307398
  • issue/automatic_updates-3307478
  • issue/automatic_updates-3307369
  • issue/automatic_updates-3308404
  • issue/automatic_updates-3307611
  • issue/automatic_updates-3308372
  • issue/automatic_updates-3308686
  • issue/automatic_updates-3308886
  • issue/automatic_updates-3309220
  • issue/automatic_updates-3309270
  • issue/automatic_updates-3308711
  • issue/automatic_updates-3309486
  • issue/automatic_updates-3305167
  • issue/automatic_updates-3309676
  • issue/automatic_updates-3309025
  • issue/automatic_updates-3309891
  • issue/automatic_updates-3308365
  • issue/automatic_updates-3310000
  • issue/automatic_updates-3309205
  • issue/automatic_updates-3248975
  • issue/automatic_updates-3310702
  • issue/automatic_updates-3272313
  • issue/automatic_updates-3310990
  • issue/automatic_updates-3310997
  • issue/automatic_updates-3311200
  • issue/automatic_updates-3310696
  • issue/automatic_updates-3311265
  • issue/automatic_updates-3303167
  • issue/automatic_updates-3310946
  • issue/automatic_updates-3308828
  • issue/automatic_updates-3310666
  • issue/automatic_updates-3109082
  • issue/automatic_updates-3311534
  • issue/automatic_updates-3311436
  • issue/automatic_updates-3308843
  • issue/automatic_updates-3303900
  • issue/automatic_updates-3312085
  • issue/automatic_updates-3312420
  • issue/automatic_updates-3312373
  • issue/automatic_updates-3312421
  • issue/automatic_updates-3312619
  • issue/automatic_updates-3312779
  • issue/automatic_updates-3275991
  • issue/automatic_updates-3312938
  • issue/automatic_updates-3312937
  • issue/automatic_updates-3312960
  • issue/automatic_updates-3312669
  • issue/automatic_updates-3312981
  • issue/automatic_updates-3313319
  • issue/automatic_updates-3313346
  • issue/automatic_updates-3310914
  • issue/automatic_updates-3317220
  • issue/automatic_updates-3317232
  • issue/automatic_updates-3317278
  • issue/automatic_updates-3317409
  • issue/automatic_updates-3317267
  • issue/automatic_updates-3317599
  • issue/automatic_updates-3317988
  • issue/automatic_updates-3318313
  • issue/automatic_updates-3313630
  • issue/automatic_updates-3313717
  • issue/automatic_updates-3313947
  • issue/automatic_updates-3313507
  • issue/automatic_updates-3276645
  • issue/automatic_updates-3313349
  • issue/automatic_updates-3314137
  • issue/automatic_updates-3314143
  • issue/automatic_updates-3304365
  • issue/automatic_updates-3317796
  • issue/automatic_updates-3317996
  • issue/automatic_updates-3316484
  • issue/automatic_updates-3313414
  • issue/automatic_updates-3314764
  • issue/automatic_updates-3314771
  • issue/automatic_updates-3314787
  • issue/automatic_updates-3314805
  • issue/automatic_updates-3314734
  • issue/automatic_updates-3298889
  • issue/automatic_updates-3314803
  • issue/automatic_updates-3315139
  • issue/automatic_updates-3314946
  • issue/automatic_updates-3315449
  • issue/automatic_updates-3315798
  • issue/automatic_updates-3315834
  • issue/automatic_updates-3309602
  • issue/automatic_updates-3316115
  • issue/automatic_updates-3316131
  • issue/automatic_updates-3316293
  • issue/automatic_updates-3316318
  • issue/automatic_updates-3310729
  • issue/automatic_updates-3315700
  • issue/automatic_updates-3316668
  • issue/automatic_updates-3316721
  • issue/automatic_updates-3306283
  • issue/automatic_updates-3316611
  • issue/automatic_updates-3316895
  • issue/automatic_updates-3317385
  • issue/automatic_updates-3318625
  • issue/automatic_updates-3318770
  • issue/automatic_updates-3318846
  • issue/automatic_updates-3318927
  • issue/automatic_updates-3318933
  • issue/automatic_updates-3319044
  • issue/automatic_updates-3319045
  • issue/automatic_updates-3325716
  • issue/automatic_updates-3324421
  • issue/automatic_updates-3320486
  • issue/automatic_updates-3320558
  • issue/automatic_updates-3321206
  • issue/automatic_updates-3326934
  • issue/automatic_updates-3320755
  • issue/automatic_updates-3328765
  • issue/automatic_updates-3325654
  • issue/automatic_updates-3325869
  • issue/automatic_updates-3277034
  • issue/automatic_updates-3328516
  • issue/automatic_updates-3322546
  • issue/automatic_updates-3320487
  • issue/automatic_updates-3320792
  • issue/automatic_updates-3320815
  • issue/automatic_updates-3320638
  • issue/automatic_updates-3321256
  • issue/automatic_updates-3321684
  • issue/automatic_updates-3325522
  • issue/automatic_updates-3326334
  • issue/automatic_updates-3328234
  • issue/automatic_updates-3328740
  • issue/automatic_updates-3316932
  • issue/automatic_updates-3321386
  • issue/automatic_updates-3321933
  • issue/automatic_updates-3321282
  • issue/automatic_updates-3326801
  • issue/automatic_updates-3327753
  • issue/automatic_updates-3328742
  • issue/automatic_updates-3322313
  • issue/automatic_updates-3322203
  • issue/automatic_updates-3322150
  • issue/automatic_updates-3322404
  • issue/automatic_updates-3321236
  • issue/automatic_updates-3321994
  • issue/automatic_updates-3322589
  • issue/automatic_updates-3321904
  • issue/automatic_updates-3316855
  • issue/automatic_updates-3322931
  • issue/automatic_updates-3322913
  • issue/automatic_updates-3323037
  • issue/automatic_updates-3322918
  • issue/automatic_updates-3323211
  • issue/automatic_updates-3323565
  • issue/automatic_updates-3320824
  • issue/automatic_updates-3320836
  • issue/automatic_updates-3329002
  • issue/automatic_updates-3321971
  • issue/automatic_updates-3299094
  • issue/automatic_updates-3248544
  • issue/automatic_updates-3330139
  • issue/automatic_updates-3330365
  • issue/automatic_updates-3330712
  • issue/automatic_updates-3318065
  • issue/automatic_updates-3331310
  • issue/automatic_updates-3322917
  • issue/automatic_updates-3330140
  • issue/automatic_updates-3323003
  • issue/automatic_updates-3331355
  • issue/automatic_updates-3334054
  • issue/automatic_updates-3323706
  • issue/automatic_updates-3331168
  • issue/automatic_updates-3343430
  • issue/automatic_updates-3344562
  • issue/automatic_updates-3345039
  • issue/automatic_updates-3344039
  • issue/automatic_updates-3345313
  • issue/automatic_updates-3331471
  • issue/automatic_updates-3334994
  • issue/automatic_updates-3327229
  • issue/automatic_updates-3343889
  • issue/automatic_updates-3344124
  • issue/automatic_updates-3344689
  • issue/automatic_updates-3316368
  • issue/automatic_updates-3345767
  • issue/automatic_updates-3317815
  • issue/automatic_updates-3328600
  • issue/automatic_updates-3332256
  • issue/automatic_updates-3335766
  • issue/automatic_updates-3335802
  • issue/automatic_updates-3335908
  • issue/automatic_updates-3336243
  • issue/automatic_updates-3336247
  • issue/automatic_updates-3336255
  • issue/automatic_updates-3336259
  • issue/automatic_updates-3337062
  • issue/automatic_updates-3337068
  • issue/automatic_updates-3311229
  • issue/automatic_updates-3337697
  • issue/automatic_updates-3343768
  • issue/automatic_updates-3344127
  • issue/automatic_updates-3344595
  • issue/automatic_updates-3345763
  • issue/automatic_updates-3345765
  • issue/automatic_updates-3345771
  • issue/automatic_updates-3337760
  • issue/automatic_updates-3337049
  • issue/automatic_updates-3342430
  • issue/automatic_updates-3319507
  • issue/automatic_updates-3344583
  • issue/automatic_updates-3345764
  • issue/automatic_updates-3345768
  • issue/automatic_updates-3338667
  • issue/automatic_updates-3339016
  • issue/automatic_updates-3343827
  • issue/automatic_updates-3344556
  • issue/automatic_updates-3338666
  • issue/automatic_updates-3345649
  • issue/automatic_updates-3339657
  • issue/automatic_updates-3339719
  • issue/automatic_updates-3338789
  • issue/automatic_updates-3340022
  • issue/automatic_updates-3340284
  • issue/automatic_updates-3340638
  • issue/automatic_updates-3316617
  • issue/automatic_updates-3341224
  • issue/automatic_updates-3337953
  • issue/automatic_updates-3339714
  • issue/automatic_updates-3341708
  • issue/automatic_updates-3341841
  • issue/automatic_updates-3326486
  • issue/automatic_updates-3321474
  • issue/automatic_updates-3341974
  • issue/automatic_updates-3342120
  • issue/automatic_updates-3342227
  • issue/automatic_updates-3323461
  • issue/automatic_updates-3342137
  • issue/automatic_updates-3342460
  • issue/automatic_updates-3342364
  • issue/automatic_updates-3345028
  • issue/automatic_updates-3345549
  • issue/automatic_updates-3345881
  • issue/automatic_updates-3345762
  • issue/automatic_updates-3342726
  • issue/automatic_updates-3337667
  • issue/automatic_updates-3343463
  • issue/automatic_updates-3345766
  • issue/automatic_updates-3345754
  • issue/automatic_updates-3345633
  • issue/automatic_updates-3345772
  • issue/automatic_updates-3345769
  • issue/automatic_updates-3345761
  • issue/automatic_updates-3345755
  • issue/automatic_updates-3345760
  • issue/automatic_updates-3345757
  • issue/automatic_updates-3346520
  • issue/automatic_updates-3346545
  • issue/automatic_updates-3337054
  • issue/automatic_updates-3346628
  • issue/automatic_updates-3346717
  • issue/automatic_updates-3346594
  • issue/automatic_updates-3346659
  • issue/automatic_updates-3347031
  • issue/automatic_updates-3346547
  • issue/automatic_updates-3347164
  • issue/automatic_updates-3318306
  • issue/automatic_updates-3345646
  • issue/automatic_updates-3347165
  • issue/automatic_updates-3347959
  • issue/automatic_updates-3347267
  • issue/automatic_updates-3338346
  • issue/automatic_updates-3348122
  • issue/automatic_updates-3348129
  • issue/automatic_updates-3348162
  • issue/automatic_updates-3348276
  • issue/automatic_updates-3348441
  • issue/automatic_updates-3351604
  • issue/automatic_updates-3316843
  • issue/automatic_updates-3351908
  • issue/automatic_updates-3352731
  • issue/automatic_updates-3352898
  • issue/automatic_updates-3348866
  • issue/automatic_updates-3342817
  • issue/automatic_updates-3348159
  • issue/automatic_updates-3349142
  • issue/automatic_updates-3349966
  • issue/automatic_updates-3350909
  • issue/automatic_updates-3351594
  • issue/automatic_updates-3351883
  • issue/automatic_updates-3351925
  • issue/automatic_updates-3352198
  • issue/automatic_updates-3334552
  • issue/automatic_updates-3342790
  • issue/automatic_updates-3345222
  • issue/automatic_updates-3351212
  • issue/automatic_updates-3351093
  • issue/automatic_updates-3351247
  • issue/automatic_updates-3351962
  • issue/automatic_updates-3345641
  • issue/automatic_updates-3338940
  • issue/automatic_updates-3343721
  • issue/automatic_updates-3351069
  • issue/automatic_updates-3353219
  • issue/automatic_updates-3159920
  • issue/automatic_updates-3352355
  • issue/automatic_updates-3341469
  • issue/automatic_updates-3354099
  • issue/automatic_updates-3339659
  • issue/automatic_updates-3354312
  • issue/automatic_updates-3351895
  • issue/automatic_updates-3354249
  • issue/automatic_updates-3353270
  • issue/automatic_updates-3354003
  • issue/automatic_updates-3355074
  • issue/automatic_updates-3355094
  • issue/automatic_updates-3355105
  • issue/automatic_updates-3355162
  • issue/automatic_updates-3355446
  • issue/automatic_updates-3355045
  • issue/automatic_updates-3355558
  • issue/automatic_updates-3355553
  • issue/automatic_updates-3354594
  • issue/automatic_updates-3355609
  • issue/automatic_updates-3354325
  • issue/automatic_updates-3356395
  • issue/automatic_updates-3356638
  • issue/automatic_updates-3356640
  • issue/automatic_updates-3354701
  • issue/automatic_updates-3355463
  • issue/automatic_updates-3356804
  • issue/automatic_updates-3357721
  • issue/automatic_updates-3357578
  • issue/automatic_updates-3357657
  • issue/automatic_updates-3304108
  • issue/automatic_updates-3357941
  • issue/automatic_updates-3358012
  • issue/automatic_updates-3345535
  • issue/automatic_updates-3354827
  • issue/automatic_updates-3358570
  • issue/automatic_updates-3358878
  • issue/automatic_updates-3355628
  • issue/automatic_updates-3358504
  • issue/automatic_updates-3357969
  • issue/automatic_updates-3359609
  • issue/automatic_updates-3359727
  • issue/automatic_updates-3359825
  • issue/automatic_updates-3359820
  • issue/automatic_updates-3360548
  • issue/automatic_updates-3357480
  • issue/automatic_updates-3360656
  • issue/automatic_updates-3360763
  • issue/automatic_updates-3360655
  • issue/automatic_updates-3355618
  • issue/automatic_updates-3349351
  • issue/automatic_updates-3362110
  • issue/automatic_updates-3362143
  • issue/automatic_updates-3362589
  • issue/automatic_updates-3349004
  • issue/automatic_updates-3362507
  • issue/automatic_updates-3362746
  • issue/automatic_updates-3362978
  • issue/automatic_updates-3363259
  • issue/automatic_updates-3363725
  • issue/automatic_updates-3363926
  • issue/automatic_updates-3363937
  • issue/automatic_updates-3364514
  • issue/automatic_updates-3284443
  • issue/automatic_updates-3363938
  • issue/automatic_updates-3364731
  • issue/automatic_updates-3364748
  • issue/automatic_updates-3364735
  • issue/automatic_updates-3364725
  • issue/automatic_updates-3359670
  • issue/automatic_updates-3365133
  • issue/automatic_updates-3365151
  • issue/automatic_updates-3365177
  • issue/automatic_updates-3364958
  • issue/automatic_updates-3365390
  • issue/automatic_updates-3365414
  • issue/automatic_updates-3346644
  • issue/automatic_updates-3366267
  • issue/automatic_updates-3366289
  • issue/automatic_updates-3366499
  • issue/automatic_updates-3365437
  • issue/automatic_updates-3354914
  • issue/automatic_updates-3253828
  • issue/automatic_updates-3345484
  • issue/automatic_updates-3352846
  • issue/automatic_updates-3368741
  • issue/automatic_updates-3406008
  • issue/automatic_updates-3406122
  • issue/automatic_updates-3370197
  • issue/automatic_updates-3366271
  • issue/automatic_updates-3364135
  • issue/automatic_updates-3370603
  • issue/automatic_updates-3400146
  • issue/automatic_updates-3406010
  • issue/automatic_updates-3404429
  • issue/automatic_updates-3371212
  • issue/automatic_updates-3371076
  • issue/automatic_updates-3372673
  • issue/automatic_updates-3368808
  • issue/automatic_updates-3374175
  • issue/automatic_updates-3374739
  • issue/automatic_updates-3374753
  • issue/automatic_updates-3375679
  • issue/automatic_updates-3377237
  • issue/automatic_updates-3377245
  • issue/automatic_updates-3378774
  • issue/automatic_updates-3378793
  • issue/automatic_updates-3360485
  • issue/automatic_updates-3380698
  • issue/automatic_updates-3375940
  • issue/automatic_updates-3381294
  • issue/automatic_updates-3381484
  • issue/automatic_updates-3382942
  • issue/automatic_updates-3383462
  • issue/automatic_updates-3383451
  • issue/automatic_updates-3384359
  • issue/automatic_updates-3384637
  • issue/automatic_updates-3375640
  • issue/automatic_updates-3386135
  • issue/automatic_updates-3386533
  • issue/automatic_updates-3387610
  • issue/automatic_updates-3387637
  • issue/automatic_updates-3387656
  • issue/automatic_updates-3389157
  • issue/automatic_updates-3388965
  • issue/automatic_updates-3387379
  • issue/automatic_updates-3390493
  • issue/automatic_updates-3308235
  • issue/automatic_updates-3391715
  • issue/automatic_updates-3392246
  • issue/automatic_updates-3393633
  • issue/automatic_updates-3379344
  • issue/automatic_updates-3394413
  • issue/automatic_updates-3394705
  • issue/automatic_updates-3394936
  • issue/automatic_updates-3363497
  • issue/automatic_updates-3395782
  • issue/automatic_updates-3341406
  • issue/automatic_updates-3311472
  • issue/automatic_updates-3397228
  • issue/automatic_updates-3352654
  • issue/automatic_updates-3398782
  • issue/automatic_updates-3399147
  • issue/automatic_updates-3399155
  • issue/automatic_updates-3406812
  • issue/automatic_updates-3408483
  • issue/automatic_updates-3408488
  • issue/automatic_updates-3408560
  • issue/automatic_updates-3437633
  • issue/automatic_updates-3408835
  • issue/automatic_updates-3408937
  • issue/automatic_updates-3409491
  • issue/automatic_updates-3409519
  • issue/automatic_updates-3377458
  • issue/automatic_updates-3437951
  • issue/automatic_updates-3409774
  • issue/automatic_updates-3410047
  • issue/automatic_updates-3411110
  • issue/automatic_updates-3411241
  • issue/automatic_updates-3411276
  • issue/automatic_updates-3411392
  • issue/automatic_updates-3411240
  • issue/automatic_updates-3412630
  • issue/automatic_updates-3413866
  • issue/automatic_updates-3414168
  • issue/automatic_updates-3415291
  • issue/automatic_updates-3415389
  • issue/automatic_updates-3415403
  • issue/automatic_updates-3416768
  • issue/automatic_updates-3417905
  • issue/automatic_updates-3420188
  • issue/automatic_updates-3421641
  • issue/automatic_updates-3424290
  • issue/automatic_updates-3426229
  • issue/automatic_updates-3426666
  • issue/automatic_updates-3426716
  • issue/automatic_updates-3428199
  • issue/automatic_updates-3436741
  • issue/automatic_updates-3441923
  • issue/automatic_updates-3428651
  • issue/automatic_updates-3441799
  • issue/automatic_updates-3443249
  • issue/automatic_updates-3428922
  • issue/automatic_updates-3441817
  • issue/automatic_updates-3429248
  • issue/automatic_updates-3440646
  • issue/automatic_updates-3437877
  • issue/automatic_updates-3445149
  • issue/automatic_updates-3429268
  • issue/automatic_updates-3442153
  • issue/automatic_updates-3438156
  • issue/automatic_updates-3436993
  • issue/automatic_updates-3437704
  • issue/automatic_updates-3432496
  • issue/automatic_updates-3440006
  • issue/automatic_updates-3435893
  • issue/automatic_updates-3441577
  • issue/automatic_updates-3437023
  • issue/automatic_updates-3432391
  • issue/automatic_updates-3432447
  • issue/automatic_updates-3432472
  • issue/automatic_updates-3432489
  • issue/automatic_updates-3432476
  • issue/automatic_updates-3435918
  • issue/automatic_updates-3439067
  • issue/automatic_updates-3437409
  • issue/automatic_updates-3449093
  • issue/automatic_updates-3449480
  • issue/automatic_updates-3449519
  • issue/automatic_updates-3449631
  • issue/automatic_updates-3449636
  • issue/automatic_updates-3449689
  • issue/automatic_updates-3449693
  • issue/automatic_updates-3450884
  • issue/automatic_updates-3452870
  • issue/automatic_updates-3453030
  • issue/automatic_updates-3455161
  • issue/automatic_updates-3459557
  • issue/automatic_updates-3462138
  • issue/automatic_updates-3462168
  • issue/automatic_updates-3462883
  • issue/automatic_updates-3463662
  • issue/automatic_updates-3463813
  • issue/automatic_updates-3446371
  • issue/automatic_updates-3465155
  • issue/automatic_updates-3340355
  • issue/automatic_updates-3467749
  • issue/automatic_updates-3473410
  • issue/automatic_updates-3441926
  • issue/automatic_updates-3474603
  • issue/automatic_updates-3484802
  • issue/automatic_updates-3498586
  • issue/automatic_updates-3499481
  • issue/automatic_updates-3498018
  • issue/automatic_updates-3506632
  • issue/automatic_updates-3511959
  • issue/automatic_updates-3511943
981 results
Show changes
Showing
with 2642 additions and 305 deletions
<?php
declare(strict_types = 1);
namespace Drupal\package_manager;
use Composer\Semver\Comparator;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\update\ProjectRelease;
use Drupal\Core\Extension\ExtensionVersion;
use Drupal\Core\Utility\Error;
use Drupal\update\UpdateManagerInterface;
/**
* Defines a class for retrieving project information from Update module.
*
* @internal
* This is an internal part of Automatic Updates and may be changed or removed
* at any time without warning. External code should use the Update API
* directly.
*/
final class ProjectInfo {
/**
* Constructs a ProjectInfo object.
*
* @param string $name
* The project name.
*/
public function __construct(private readonly string $name) {
}
/**
* Determines if a release can be installed.
*
* @param \Drupal\update\ProjectRelease $release
* The project release.
* @param string[] $support_branches
* The supported branches.
*
* @return bool
* TRUE if the release is installable, otherwise FALSE. A release will be
* considered installable if it is secure, published, supported, and in
* a supported branch.
*/
private function isInstallable(ProjectRelease $release, array $support_branches): bool {
if ($release->isInsecure() || !$release->isPublished() || $release->isUnsupported()) {
return FALSE;
}
$version = ExtensionVersion::createFromVersionString($release->getVersion());
if ($version->getVersionExtra() === 'dev') {
return FALSE;
}
foreach ($support_branches as $support_branch) {
$support_branch_version = ExtensionVersion::createFromSupportBranch($support_branch);
if ($support_branch_version->getMajorVersion() === $version->getMajorVersion() && $support_branch_version->getMinorVersion() === $version->getMinorVersion()) {
return TRUE;
}
}
return FALSE;
}
/**
* Returns up-to-date project information.
*
* @return mixed[]|null
* The retrieved project information.
*
* @throws \RuntimeException
* If data about available updates cannot be retrieved.
*/
public function getProjectInfo(): ?array {
$available_updates = $this->getAvailableProjects();
$project_data = update_calculate_project_data($available_updates);
if (!isset($project_data[$this->name])) {
return $available_updates[$this->name] ?? NULL;
}
return $project_data[$this->name];
}
/**
* Gets all project releases to which the site can update.
*
* @return \Drupal\update\ProjectRelease[]|null
* If the project information is available, an array of releases that can be
* installed, keyed by version number; otherwise NULL. The releases are in
* descending order by version number (i.e., higher versions are listed
* first). The currently installed version of the project, and any older
* versions, are not considered installable releases.
*
* @throws \RuntimeException
* Thrown if there are no available releases.
*
* @todo Remove or simplify this function in https://www.drupal.org/i/3252190.
*/
public function getInstallableReleases(): ?array {
$project = $this->getProjectInfo();
if (!$project) {
return NULL;
}
$available_updates = $this->getAvailableProjects()[$this->name];
if ($available_updates['project_status'] !== 'published') {
throw new \RuntimeException("The project '{$this->name}' can not be updated because its status is " . $available_updates['project_status']);
}
$installed_version = $this->getInstalledVersion();
if ($installed_version && empty($available_updates['releases'])) {
// If project is installed but not current we should always have at
// least one release.
throw new \RuntimeException('There was a problem getting update information. Try again later.');
}
$support_branches = explode(',', $available_updates['supported_branches']);
$installable_releases = [];
foreach ($available_updates['releases'] as $release_info) {
try {
$release = ProjectRelease::createFromArray($release_info);
}
catch (\UnexpectedValueException $exception) {
// Ignore releases that are in an invalid format. Although this is
// unlikely we should still only process releases in the correct format.
\Drupal::logger('package_manager')
->error(sprintf('Invalid project format: %s', print_r($release_info, TRUE)), Error::decodeException($exception));
continue;
}
$version = $release->getVersion();
if ($installed_version) {
$semantic_version = LegacyVersionUtility::convertToSemanticVersion($version);
$semantic_installed_version = LegacyVersionUtility::convertToSemanticVersion($installed_version);
if (Comparator::lessThanOrEqualTo($semantic_version, $semantic_installed_version)) {
// If the project is installed stop searching for releases as soon as
// we find the installed version.
break;
}
}
if ($this->isInstallable($release, $support_branches)) {
$installable_releases[$version] = $release;
}
}
return $installable_releases;
}
/**
* Returns the installed project version, according to the Update module.
*
* @return string|null
* The installed project version as known to the Update module or NULL if
* the project information is not available.
*/
public function getInstalledVersion(): ?string {
$project_data = $this->getProjectInfo();
if ($project_data && array_key_exists('existing_version', $project_data)) {
$existing_version = $project_data['existing_version'];
// Treat an unknown version the same as a project whose project
// information is not available, so return NULL.
// @see \update_process_project_info()
if ($existing_version instanceof TranslatableMarkup && $existing_version->getUntranslatedString() === 'Unknown') {
return NULL;
}
// TRICKY: Since this is relying on data coming from
// \Drupal\update\UpdateManager::getProjects(), we cannot be certain that
// we are actually receiving strings.
// @see \Drupal\update\UpdateManager::getProjects()
if (!is_string($existing_version)) {
return NULL;
}
return $existing_version;
}
return NULL;
}
/**
* Gets the available projects.
*
* @return array
* The available projects keyed by project machine name in the format
* returned by update_get_available(). If the project specified in ::name is
* not returned from update_get_available() this project will be explicitly
* fetched and added the return value of this function.
*
* @see \update_get_available()
*/
private function getAvailableProjects(): array {
$available_projects = update_get_available(TRUE);
// update_get_available() will only returns projects that are in the active
// codebase. If the project specified by ::name is not returned in
// $available_projects, it means it is not in the active codebase, therefore
// we will retrieve the project information from Package Manager's own
// update processor service.
if (!isset($available_projects[$this->name])) {
/** @var \Drupal\package_manager\PackageManagerUpdateProcessor $update_processor */
$update_processor = \Drupal::service(PackageManagerUpdateProcessor::class);
if ($project_data = $update_processor->getProjectData($this->name)) {
$available_projects[$this->name] = $project_data;
}
}
return $available_projects;
}
/**
* Checks if the installed version of this project is safe to use.
*
* @return bool
* TRUE if the installed version of this project is secure, supported, and
* published. Otherwise, or if the project information could not be
* retrieved, returns FALSE.
*/
public function isInstalledVersionSafe(): bool {
$project_data = $this->getProjectInfo();
if ($project_data) {
$unsafe = [
UpdateManagerInterface::NOT_SECURE,
UpdateManagerInterface::NOT_SUPPORTED,
UpdateManagerInterface::REVOKED,
];
return !in_array($project_data['status'], $unsafe, TRUE);
}
// If we couldn't get project data, assume the installed version is unsafe.
return FALSE;
}
/**
* Gets the supported branches of the project.
*
* @return string[]
* The supported branches.
*/
public function getSupportedBranches(): array {
$available_updates = $this->getAvailableProjects()[$this->name];
return isset($available_updates['supported_branches']) ? explode(',', $available_updates['supported_branches']) : [];
}
}
<?php
declare(strict_types = 1);
namespace Drupal\package_manager;
use Composer\Semver\VersionParser;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Component\FileSystem\FileSystem;
use Drupal\Component\Utility\Crypt;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\File\Exception\FileException;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Component\Utility\Random;
use Drupal\Core\Queue\QueueFactory;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\TempStore\SharedTempStore;
use Drupal\Core\TempStore\SharedTempStoreFactory;
use Drupal\Core\Utility\Error;
use Drupal\package_manager\Event\CollectPathsToExcludeEvent;
use Drupal\package_manager\Event\PostApplyEvent;
use Drupal\package_manager\Event\PostCreateEvent;
use Drupal\package_manager\Event\PostDestroyEvent;
use Drupal\package_manager\Event\PostRequireEvent;
use Drupal\package_manager\Event\PreApplyEvent;
use Drupal\package_manager\Event\PreCreateEvent;
use Drupal\package_manager\Event\PreDestroyEvent;
use Drupal\package_manager\Event\PreOperationStageEvent;
use Drupal\package_manager\Event\PreRequireEvent;
use Drupal\package_manager\Event\StageEvent;
use Drupal\package_manager\Exception\ApplyFailedException;
use Drupal\package_manager\Exception\StageEventException;
use Drupal\package_manager\Exception\StageException;
use Drupal\package_manager\Exception\StageOwnershipException;
use Drupal\package_manager\Exception\StageValidationException;
use PhpTuf\ComposerStager\Domain\BeginnerInterface;
use PhpTuf\ComposerStager\Domain\CommitterInterface;
use PhpTuf\ComposerStager\Domain\StagerInterface;
use PhpTuf\ComposerStager\API\Core\BeginnerInterface;
use PhpTuf\ComposerStager\API\Core\CommitterInterface;
use PhpTuf\ComposerStager\API\Core\StagerInterface;
use PhpTuf\ComposerStager\API\Exception\InvalidArgumentException;
use PhpTuf\ComposerStager\API\Exception\PreconditionException;
use PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface;
use PhpTuf\ComposerStager\API\Path\Value\PathListInterface;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Psr\Log\NullLogger;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
/**
* Creates and manages a staging area in which to install or update code.
* Creates and manages a stage directory in which to install or update code.
*
* Allows calling code to copy the current Drupal site into a temporary staging
* Allows calling code to copy the current Drupal site into a temporary stage
* directory, use Composer to require packages into it, sync changes from the
* staging directory back into the active code base, and then delete the
* staging directory.
* stage directory back into the active code base, and then delete the
* stage directory.
*
* Only one staging area can exist at any given time, and the stage is owned by
* the user or session that originally created it. Only the owner can perform
* operations on the staging area, and the stage must be "claimed" by its owner
* before any such operations are done. A stage is claimed by presenting a
* unique token that is generated when the stage is created.
* Only one stage directory can exist at any given time, and the stage is
* owned by the user or session that originally created it. Only the owner can
* perform operations on the stage directory, and the stage must be "claimed"
* by its owner before any such operations are done. A stage is claimed by
* presenting a unique token that is generated when the stage is created.
*
* Although a site can only have one staging area, it is possible for privileged
* users to destroy a stage created by another user. To prevent such actions
* from putting the file system into an uncertain state (for example, if a stage
* is destroyed by another user while it is still being created), the staging
* directory has a randomly generated name. For additional cleanliness, all
* staging directories created by a specific site live in a single directory,
* called the "staging root" and identified by the UUID of the current site
* (e.g. `/tmp/.package_managerSITE_UUID`), which is deleted when any stage
* Although a site can only have one stage directory, it is possible for
* privileged users to destroy a stage created by another user. To prevent such
* actions from putting the file system into an uncertain state (for example, if
* a stage is destroyed by another user while it is still being created), the
* stage directory has a randomly generated name. For additional cleanliness,
* all stage directories created by a specific site live in a single directory
* ,called the "stage root directory" and identified by the UUID of the current
* site (e.g. `/tmp/.package_managerSITE_UUID`), which is deleted when any stage
* created by that site is destroyed.
*/
class Stage {
abstract class StageBase implements LoggerAwareInterface {
use LoggerAwareTrait;
use StringTranslationTrait;
/**
* The tempstore key under which to store the locking info for this stage.
*
* @var string
*/
protected const TEMPSTORE_LOCK_KEY = 'lock';
final protected const TEMPSTORE_LOCK_KEY = 'lock';
/**
* The tempstore key under which to store arbitrary metadata for this stage.
*
* @var string
*/
protected const TEMPSTORE_METADATA_KEY = 'metadata';
final protected const TEMPSTORE_METADATA_KEY = 'metadata';
/**
* The tempstore key under which to store the path of stage root directory.
*
* @var string
*
* @see ::getStagingRoot()
*/
private const TEMPSTORE_STAGING_ROOT_KEY = 'staging_root';
/**
* The tempstore key under which to store the time that ::apply() was called.
......@@ -78,116 +101,145 @@ class Stage {
private const TEMPSTORE_APPLY_TIME_KEY = 'apply_time';
/**
* The config factory service.
* The tempstore key for whether staged operations have been applied.
*
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected $configFactory;
/**
* The path locator service.
* @var string
*
* @var \Drupal\package_manager\PathLocator
* @see ::apply()
* @see ::destroy()
*/
protected $pathLocator;
private const TEMPSTORE_CHANGES_APPLIED = 'changes_applied';
/**
* The beginner service.
* The tempstore key for information about previously destroyed stages.
*
* @var \PhpTuf\ComposerStager\Domain\BeginnerInterface
*/
protected $beginner;
/**
* The stager service.
* @var string
*
* @var \PhpTuf\ComposerStager\Domain\StagerInterface
* @see ::apply()
* @see ::destroy()
*/
protected $stager;
private const TEMPSTORE_DESTROYED_STAGES_INFO_PREFIX = 'TEMPSTORE_DESTROYED_STAGES_INFO';
/**
* The committer service.
* The regular expression to check if a package name is a platform package.
*
* @var string
*
* @var \PhpTuf\ComposerStager\Domain\CommitterInterface
* @see \Composer\Repository\PlatformRepository::PLATFORM_PACKAGE_REGEX
* @see ::validateRequirements()
*/
protected $committer;
private const COMPOSER_PLATFORM_PACKAGE_REGEX = '{^(?:php(?:-64bit|-ipv6|-zts|-debug)?|hhvm|(?:ext|lib)-[a-z0-9](?:[_.-]?[a-z0-9]+)*|composer(?:-(?:plugin|runtime)-api)?)$}iD';
/**
* The file system service.
* The regular expression to check if a package name is a regular package.
*
* @var \Drupal\Core\File\FileSystemInterface
* If you try to require an invalid package name, this is the regular
* expression that Composer will, at the command line, tell you to match.
*
* @var string
*
* @see \Composer\Package\Loader\ValidatingArrayLoader::hasPackageNamingError()
* @see ::validateRequirements()
*/
protected $fileSystem;
private const COMPOSER_PACKAGE_REGEX = '/^[a-z0-9]([_.-]?[a-z0-9]+)*\/[a-z0-9](([_.]?|-{0,2})[a-z0-9]+)*$/';
/**
* The event dispatcher service.
* The lock info for the stage.
*
* Consists of a unique random string and the current class name.
*
* @var \Symfony\Contracts\EventDispatcher\EventDispatcherInterface
* @var string[]
*/
protected $eventDispatcher;
private $lock;
/**
* The shared temp store.
*
* @var \Drupal\Core\TempStore\SharedTempStore
*/
protected $tempStore;
protected SharedTempStore $tempStore;
/**
* The time service.
* The stage type.
*
* @var \Drupal\Component\Datetime\TimeInterface
*/
protected $time;
/**
* The lock info for the stage.
*
* Consists of a unique random string and the current class name.
* To ensure that stage classes do not unintentionally use another stage's
* type, all concrete subclasses MUST explicitly define this property.
* The recommended pattern is `MODULE:TYPE`.
*
* @var string[]
* @var string
*/
private $lock;
protected string $type;
/**
* Constructs a new Stage object.
*
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The config factory service.
* @param \Drupal\package_manager\PathLocator $path_locator
* @param \Drupal\package_manager\PathLocator $pathLocator
* The path locator service.
* @param \PhpTuf\ComposerStager\Domain\BeginnerInterface $beginner
* @param \PhpTuf\ComposerStager\API\Core\BeginnerInterface $beginner
* The beginner service.
* @param \PhpTuf\ComposerStager\Domain\StagerInterface $stager
* @param \PhpTuf\ComposerStager\API\Core\StagerInterface $stager
* The stager service.
* @param \PhpTuf\ComposerStager\Domain\CommitterInterface $committer
* @param \PhpTuf\ComposerStager\API\Core\CommitterInterface $committer
* The committer service.
* @param \Drupal\Core\File\FileSystemInterface $file_system
* The file system service.
* @param \Symfony\Contracts\EventDispatcher\EventDispatcherInterface $event_dispatcher
* @param \Drupal\Core\Queue\QueueFactory $queueFactory
* The queue factory.
* @param \Symfony\Contracts\EventDispatcher\EventDispatcherInterface $eventDispatcher
* The event dispatcher service.
* @param \Drupal\Core\TempStore\SharedTempStoreFactory $shared_tempstore
* @param \Drupal\Core\TempStore\SharedTempStoreFactory $tempStoreFactory
* The shared tempstore factory.
* @param \Drupal\Component\Datetime\TimeInterface $time
* The time service.
* @param \PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface $pathFactory
* The path factory service.
* @param \Drupal\package_manager\FailureMarker $failureMarker
* The failure marker service.
*/
public function __construct(ConfigFactoryInterface $config_factory, PathLocator $path_locator, BeginnerInterface $beginner, StagerInterface $stager, CommitterInterface $committer, FileSystemInterface $file_system, EventDispatcherInterface $event_dispatcher, SharedTempStoreFactory $shared_tempstore, TimeInterface $time) {
$this->configFactory = $config_factory;
$this->pathLocator = $path_locator;
$this->beginner = $beginner;
$this->stager = $stager;
$this->committer = $committer;
$this->fileSystem = $file_system;
$this->eventDispatcher = $event_dispatcher;
$this->time = $time;
$this->tempStore = $shared_tempstore->get('package_manager_stage');
public function __construct(
protected readonly PathLocator $pathLocator,
protected readonly BeginnerInterface $beginner,
protected readonly StagerInterface $stager,
protected readonly CommitterInterface $committer,
protected readonly QueueFactory $queueFactory,
protected EventDispatcherInterface $eventDispatcher,
protected readonly SharedTempStoreFactory $tempStoreFactory,
protected readonly TimeInterface $time,
protected readonly PathFactoryInterface $pathFactory,
protected readonly FailureMarker $failureMarker,
) {
$this->tempStore = $tempStoreFactory->get('package_manager_stage');
$this->setLogger(new NullLogger());
}
/**
* Determines if the staging area can be created.
* Gets the stage type.
*
* The stage type can be used by stage event subscribers to implement logic
* specific to certain stages, without relying on the class name (which may
* not be part of module's public API).
*
* @return string
* The stage type.
*
* @throws \LogicException
* Thrown if $this->type is not explicitly overridden.
*/
final public function getType(): string {
$reflector = new \ReflectionProperty($this, 'type');
// The $type property must ALWAYS be overridden. This means that different
// subclasses can return the same value (thus allowing one stage to
// impersonate another one), but if that happens, it is intentional.
if ($reflector->getDeclaringClass()->getName() === static::class) {
return $this->type;
}
throw new \LogicException(static::class . ' must explicitly override the $type property.');
}
/**
* Determines if the stage directory can be created.
*
* @return bool
* TRUE if the staging area can be created, otherwise FALSE.
* TRUE if the stage directory can be created, otherwise FALSE.
*/
final public function isAvailable(): bool {
return empty($this->tempStore->getMetadata(static::TEMPSTORE_LOCK_KEY));
......@@ -205,10 +257,10 @@ class Stage {
* @return mixed
* The metadata value, or NULL if it is not set.
*/
protected function getMetadata(string $key) {
public function getMetadata(string $key) {
$this->checkOwnership();
$metadata = $this->tempStore->getIfOwner(static::TEMPSTORE_METADATA_KEY) ?: [];
$metadata = $this->tempStore->get(static::TEMPSTORE_METADATA_KEY) ?: [];
return $metadata[$key] ?? NULL;
}
......@@ -219,11 +271,13 @@ class Stage {
* claimed by its owner, or created during the current request.
*
* @param string $key
* The key under which to store the metadata.
* The key under which to store the metadata. To prevent conflicts, it is
* strongly recommended that this be prefixed with the name of the module
* storing the data.
* @param mixed $data
* The metadata to store.
*/
protected function setMetadata(string $key, $data): void {
public function setMetadata(string $key, $data): void {
$this->checkOwnership();
$metadata = $this->tempStore->get(static::TEMPSTORE_METADATA_KEY);
......@@ -232,107 +286,265 @@ class Stage {
}
/**
* Copies the active code base into the staging area.
* Collects paths that Composer Stager should exclude.
*
* @return \PhpTuf\ComposerStager\API\Path\Value\PathListInterface
* A list of paths that Composer Stager should exclude when creating the
* stage directory and applying staged changes to the active directory.
*
* @throws \Drupal\package_manager\Exception\StageException
* Thrown if an exception occurs while collecting paths to exclude.
*
* @see ::create()
* @see ::apply()
*/
protected function getPathsToExclude(): PathListInterface {
$event = new CollectPathsToExcludeEvent($this, $this->pathLocator, $this->pathFactory);
try {
return $this->eventDispatcher->dispatch($event);
}
catch (\Throwable $e) {
$this->rethrowAsStageException($e);
}
}
/**
* Copies the active code base into the stage directory.
*
* This will automatically claim the stage, so external code does NOT need to
* call ::claim(). However, if it was created during another request, the
* stage must be claimed before operations can be performed on it.
*
* @param int|null $timeout
* (optional) How long to allow the file copying operation to run before
* timing out, in seconds, or NULL to never time out. Defaults to 300
* seconds.
*
* @return string
* Unique ID for the stage, which can be used to claim the stage before
* performing other operations on it. Calling code should store this ID for
* as long as the stage needs to exist.
*
* @throws \Drupal\package_manager\Exception\StageException
* Thrown if a staging area already exists.
* Thrown if a stage directory already exists, or if an error occurs while
* creating the stage directory. In the latter situation, the stage
* directory will be destroyed.
*
* @see ::claim()
*/
public function create(): string {
public function create(?int $timeout = 300): string {
$this->failureMarker->assertNotExists();
if (!$this->isAvailable()) {
throw new StageException('Cannot create a new stage because one already exists.');
throw new StageException($this, 'Cannot create a new stage because one already exists.');
}
// Mark the stage as unavailable as early as possible, before dispatching
// the pre-create event. The idea is to prevent a race condition if the
// event subscribers take a while to finish, and two different users attempt
// to create a staging area at around the same time. If an error occurs
// to create a stage directory at around the same time. If an error occurs
// while the event is being processed, the stage is marked as available.
// @see ::dispatch()
$id = Crypt::randomBytesBase64();
$this->tempStore->set(static::TEMPSTORE_LOCK_KEY, [$id, static::class]);
// We specifically generate a random 32-character alphanumeric name in order
// to guarantee that the the stage ID won't start with -, which could cause
// it to be interpreted as an option if it's used as a command-line
// argument. (For example,
// \Drupal\Component\Utility\Crypt::randomBytesBase64() would be vulnerable
// to this; the stage ID needs to be unique, but not cryptographically so.)
$id = (new Random())->name(32);
// Re-acquire the tempstore to ensure that the lock is written by whoever is
// actually logged in (or not) right now, since it's possible that the stage
// was instantiated (i.e., __construct() was called) by a different session,
// which would result in the lock having the wrong owner and the stage not
// being claimable by whoever is actually creating it.
$this->tempStore = $this->tempStoreFactory->get('package_manager_stage');
// For the lock value, we use both the stage's class and its type in order
// to prevent a stage from being manipulated by two different classes during
// a single life cycle.
$this->tempStore->set(static::TEMPSTORE_LOCK_KEY, [
$id,
static::class,
$this->getType(),
]);
$this->claim($id);
$active_dir = $this->pathLocator->getProjectRoot();
$stage_dir = $this->getStageDirectory();
$active_dir = $this->pathFactory->create($this->pathLocator->getProjectRoot());
$stage_dir = $this->pathFactory->create($this->getStageDirectory());
$event = new PreCreateEvent($this);
$excluded_paths = $this->getPathsToExclude();
$event = new PreCreateEvent($this, $excluded_paths);
// If an error occurs and we won't be able to create the stage, mark it as
// available.
$this->dispatch($event, [$this, 'markAsAvailable']);
$this->beginner->begin($active_dir, $stage_dir, $event->getExcludedPaths());
try {
$this->beginner->begin($active_dir, $stage_dir, $excluded_paths, NULL, $timeout);
}
catch (\Throwable $error) {
$this->destroy();
$this->rethrowAsStageException($error);
}
$this->dispatch(new PostCreateEvent($this));
return $id;
}
/**
* Adds or updates packages in the staging area.
* Wraps an exception in a StageException and re-throws it.
*
* @param \Throwable $e
* The throwable to wrap.
*/
private function rethrowAsStageException(\Throwable $e): never {
throw new StageException($this, $e->getMessage(), $e->getCode(), $e);
}
/**
* Adds or updates packages in the stage directory.
*
* @param string[] $runtime
* The packages to add as regular top-level dependencies, in the form
* 'vendor/name:version'.
* 'vendor/name' or 'vendor/name:version'.
* @param string[] $dev
* (optional) The packages to add as dev dependencies, in the form
* 'vendor/name:version'. Defaults to an empty array.
* 'vendor/name' or 'vendor/name:version'. Defaults to an empty array.
* @param int|null $timeout
* (optional) How long to allow the Composer operation to run before timing
* out, in seconds, or NULL to never time out. Defaults to 300 seconds.
*
* @throws \Drupal\package_manager\Exception\StageException
* Thrown if the Composer operation cannot be started, or if an error occurs
* during the operation. In the latter situation, the stage directory will
* be destroyed.
*/
public function require(array $runtime, array $dev = []): void {
public function require(array $runtime, array $dev = [], ?int $timeout = 300): void {
$this->checkOwnership();
$this->dispatch(new PreRequireEvent($this));
$dir = $this->getStageDirectory();
$this->dispatch(new PreRequireEvent($this, $runtime, $dev));
// A helper function to execute a command in the stage, destroying it if an
// exception occurs in the middle of a Composer operation.
$do_stage = function (array $command) use ($timeout): void {
$active_dir = $this->pathFactory->create($this->pathLocator->getProjectRoot());
$stage_dir = $this->pathFactory->create($this->getStageDirectory());
try {
$this->stager->stage($command, $active_dir, $stage_dir, NULL, $timeout);
}
catch (\Throwable $e) {
// If the caught exception isn't InvalidArgumentException or
// PreconditionException, a Composer operation was actually attempted,
// and failed. The stage should therefore be destroyed, because it's in
// an indeterminate and possibly unrecoverable state.
if (!$e instanceof InvalidArgumentException && !$e instanceof PreconditionException) {
$this->destroy();
}
$this->rethrowAsStageException($e);
}
};
// Change the runtime and dev requirements as needed, but don't update
// the installed packages yet.
if ($runtime) {
self::validateRequirements($runtime);
$command = array_merge(['require', '--no-update'], $runtime);
$this->stager->stage($command, $dir);
$do_stage($command);
}
if ($dev) {
self::validateRequirements($dev);
$command = array_merge(['require', '--dev', '--no-update'], $dev);
$this->stager->stage($command, $dir);
$do_stage($command);
}
// If constraints were changed, update those packages.
if ($runtime || $dev) {
$command = array_merge(['update', '--with-all-dependencies'], $runtime, $dev);
$this->stager->stage($command, $dir);
$command = array_merge(['update', '--with-all-dependencies', '--optimize-autoloader'], $runtime, $dev);
$do_stage($command);
}
$this->dispatch(new PostRequireEvent($this));
$this->dispatch(new PostRequireEvent($this, $runtime, $dev));
}
/**
* Applies staged changes to the active directory.
*
* After the staged changes are applied, the current request should be
* terminated as soon as possible. This is because the code loaded into the
* PHP runtime may no longer match the code that is physically present in the
* active code base, which means that the current request is running in an
* unreliable, inconsistent environment. In the next request,
* ::postApply() should be called as early as possible after Drupal is
* fully bootstrapped, to rebuild the service container, flush caches, and
* dispatch the post-apply event.
*
* @param int|null $timeout
* (optional) How long to allow the file copying operation to run before
* timing out, in seconds, or NULL to never time out. Defaults to 600
* seconds.
*
* @throws \Drupal\package_manager\Exception\ApplyFailedException
* Thrown if there is an error calling Composer Stager, which may indicate
* a failed commit operation.
*/
public function apply(): void {
public function apply(?int $timeout = 600): void {
$this->checkOwnership();
$active_dir = $this->pathLocator->getProjectRoot();
$stage_dir = $this->getStageDirectory();
$active_dir = $this->pathFactory->create($this->pathLocator->getProjectRoot());
$stage_dir = $this->pathFactory->create($this->getStageDirectory());
$excluded_paths = $this->getPathsToExclude();
$event = new PreApplyEvent($this, $excluded_paths);
// If an error occurs while dispatching the events, ensure that ::destroy()
// doesn't think we're in the middle of applying the staged changes to the
// active directory.
$release_apply = function (): void {
$this->tempStore->delete(self::TEMPSTORE_APPLY_TIME_KEY);
};
$event = new PreApplyEvent($this);
$this->tempStore->set(self::TEMPSTORE_APPLY_TIME_KEY, $this->time->getRequestTime());
$this->dispatch($event, $release_apply);
$this->dispatch($event, $this->setNotApplying(...));
$this->committer->commit($stage_dir, $active_dir, $event->getExcludedPaths());
// Create a marker file so that we can tell later on if the commit failed.
$this->failureMarker->write($this, $this->getFailureMarkerMessage());
try {
$this->committer->commit($stage_dir, $active_dir, $excluded_paths, NULL, $timeout);
}
catch (InvalidArgumentException | PreconditionException $e) {
// The commit operation has not started yet, so we can clear the failure
// marker and release the flag that says we're applying.
$this->setNotApplying();
$this->failureMarker->clear();
$this->rethrowAsStageException($e);
}
catch (\Throwable $throwable) {
// The commit operation may have failed midway through, and the site code
// is in an indeterminate state. Release the flag which says we're still
// applying, because in this situation, the site owner should probably
// restore everything from a backup.
$this->setNotApplying();
// Update the marker file with the information from the throwable.
$this->failureMarker->write($this, $this->getFailureMarkerMessage(), $throwable);
throw new ApplyFailedException($this, $this->failureMarker->getMessage(), $throwable->getCode(), $throwable);
}
$this->failureMarker->clear();
$this->setMetadata(self::TEMPSTORE_CHANGES_APPLIED, TRUE);
}
/**
* Returns a closure that marks this stage as no longer being applied.
*/
private function setNotApplying(): void {
$this->tempStore->delete(self::TEMPSTORE_APPLY_TIME_KEY);
}
/**
* Performs post-apply tasks.
*
* This should be called as soon as possible after ::apply(), in a new
* request.
*
* @see ::apply()
*/
public function postApply(): void {
$this->checkOwnership();
if ($this->tempStore->get(self::TEMPSTORE_APPLY_TIME_KEY) === $this->time->getRequestTime()) {
$this->logger->warning('Post-apply tasks are running in the same request during which staged changes were applied to the active code base. This can result in unpredictable behavior.');
}
// Rebuild the container and clear all caches, to ensure that new services
// are picked up.
drupal_flush_all_caches();
......@@ -341,43 +553,42 @@ class Stage {
// unlikely to call newly added code during the current request.
$this->eventDispatcher = \Drupal::service('event_dispatcher');
$release_apply = $this->setNotApplying(...);
$this->dispatch(new PostApplyEvent($this), $release_apply);
$release_apply();
}
/**
* Deletes the staging area.
* Deletes the stage directory.
*
* @param bool $force
* (optional) If TRUE, the staging area will be destroyed even if it is not
* owned by the current user or session. Defaults to FALSE.
* (optional) If TRUE, the stage directory will be destroyed even if it is
* not owned by the current user or session. Defaults to FALSE.
* @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $message
* (optional) A message about why the stage was destroyed.
*
* @throws \Drupal\package_manager\Exception\StageException
* If the staged changes are being applied to the active directory.
* @throws \Drupal\Core\TempStore\TempStoreException
*/
public function destroy(bool $force = FALSE): void {
public function destroy(bool $force = FALSE, ?TranslatableMarkup $message = NULL): void {
if (!$force) {
$this->checkOwnership();
}
if ($this->isApplying()) {
throw new StageException('Cannot destroy the staging area while it is being applied to the active directory.');
throw new StageException($this, 'Cannot destroy the stage directory while it is being applied to the active directory.');
}
$this->dispatch(new PreDestroyEvent($this));
// Delete the staging root and everything in it.
try {
$this->fileSystem->deleteRecursive($this->getStagingRoot(), function (string $path): void {
$this->fileSystem->chmod($path, 0777);
});
}
catch (FileException $e) {
// Deliberately swallow the exception so that the stage will be marked
// as available and the post-destroy event will be fired, even if the
// staging area can't actually be deleted. The file system service logs
// the exception, so we don't need to do anything else here.
// If the stage directory exists, queue it to be automatically cleaned up
// later by a queue (which may or may not happen during cron).
// @see \Drupal\package_manager\Plugin\QueueWorker\Cleaner
if ($this->stageDirectoryExists()) {
$this->queueFactory->get('package_manager_cleanup')
->createItem($this->getStageDirectory());
}
$this->storeDestroyInfo($force, $message);
$this->markAsAvailable();
$this->dispatch(new PostDestroyEvent($this));
}
/**
......@@ -386,6 +597,7 @@ class Stage {
protected function markAsAvailable(): void {
$this->tempStore->delete(static::TEMPSTORE_METADATA_KEY);
$this->tempStore->delete(static::TEMPSTORE_LOCK_KEY);
$this->tempStore->delete(self::TEMPSTORE_STAGING_ROOT_KEY);
$this->lock = NULL;
}
......@@ -398,27 +610,26 @@ class Stage {
* (optional) A callback function to call if an error occurs, before any
* exceptions are thrown.
*
* @throws \Drupal\package_manager\Exception\StageValidationException
* @throws \Drupal\package_manager\Exception\StageEventException
* If the event collects any validation errors.
* @throws \Drupal\package_manager\Exception\StageException
* If any other sort of error occurs.
*/
protected function dispatch(StageEvent $event, callable $on_error = NULL): void {
try {
$this->eventDispatcher->dispatch($event);
if ($event instanceof PreOperationStageEvent) {
$results = $event->getResults();
if ($results) {
$error = new StageValidationException($results);
if ($event->getResults()) {
$error = new StageEventException($event);
}
}
}
catch (\Throwable $error) {
$error = new StageException($error->getMessage(), $error->getCode(), $error);
$error = new StageEventException($event, $error->getMessage(), $error->getCode(), $error);
}
if (isset($error)) {
// Ensure the error is logged for post-mortem diagnostics.
Error::logException($this->logger, $error);
if ($on_error) {
$on_error();
}
......@@ -426,28 +637,6 @@ class Stage {
}
}
/**
* Returns a Composer utility object for the active directory.
*
* @return \Drupal\package_manager\ComposerUtility
* The Composer utility object.
*/
public function getActiveComposer(): ComposerUtility {
$dir = $this->pathLocator->getProjectRoot();
return ComposerUtility::createForDirectory($dir);
}
/**
* Returns a Composer utility object for the stage directory.
*
* @return \Drupal\package_manager\ComposerUtility
* The Composer utility object.
*/
public function getStageComposer(): ComposerUtility {
$dir = $this->getStageDirectory();
return ComposerUtility::createForDirectory($dir);
}
/**
* Attempts to claim the stage.
*
......@@ -466,7 +655,7 @@ class Stage {
*
* @return $this
*
* @throws \Drupal\package_manager\Exception\StageException
* @throws \Drupal\package_manager\Exception\StageOwnershipException
* If the stage cannot be claimed. This can happen if the current user or
* session did not originally create the stage, if $unique_id doesn't match
* the unique ID that was generated when the stage was created, or the
......@@ -475,24 +664,58 @@ class Stage {
* @see ::create()
*/
final public function claim(string $unique_id): self {
$this->failureMarker->assertNotExists();
if ($this->isAvailable()) {
throw new StageException('Cannot claim the stage because no stage has been created.');
// phpcs:disable DrupalPractice.General.ExceptionT.ExceptionT
// @see https://www.drupal.org/project/automatic_updates/issues/3338651
throw new StageException($this, $this->computeDestroyMessage(
$unique_id,
$this->t('Cannot claim the stage because no stage has been created.')
)->render());
}
$stored_lock = $this->tempStore->getIfOwner(self::TEMPSTORE_LOCK_KEY);
$stored_lock = $this->tempStore->getIfOwner(static::TEMPSTORE_LOCK_KEY);
if (!$stored_lock) {
throw new StageOwnershipException('Cannot claim the stage because it is not owned by the current user or session.');
throw new StageOwnershipException($this, $this->computeDestroyMessage(
$unique_id,
$this->t('Cannot claim the stage because it is not owned by the current user or session.')
)->render());
}
if ($stored_lock === [$unique_id, static::class]) {
if ($stored_lock === [$unique_id, static::class, $this->getType()]) {
$this->lock = $stored_lock;
return $this;
}
throw new StageOwnershipException('Cannot claim the stage because the current lock does not match the stored lock.');
throw new StageOwnershipException($this, $this->computeDestroyMessage(
$unique_id,
$this->t('Cannot claim the stage because the current lock does not match the stored lock.')
)->render());
// phpcs:enable DrupalPractice.General.ExceptionT.ExceptionT
}
/**
* Returns the specific destroy message for the ID.
*
* @param string $unique_id
* The unique ID that was returned by ::create().
* @param \Drupal\Core\StringTranslation\TranslatableMarkup $fallback_message
* A fallback message, in case no specific message was stored.
*
* @return \Drupal\Core\StringTranslation\TranslatableMarkup
* A message describing why the stage with the given ID was destroyed, or if
* no message was associated with that destroyed stage, the provided
* fallback message.
*/
private function computeDestroyMessage(string $unique_id, TranslatableMarkup $fallback_message): TranslatableMarkup {
// Check to see if we have a specific message about a stage with a
// specific ID that was given.
return $this->tempStore->get(self::TEMPSTORE_DESTROYED_STAGES_INFO_PREFIX . $unique_id) ?? $fallback_message;
}
/**
* Validates the ownership of staging area.
* Validates the ownership of stage directory.
*
* The stage is considered under valid ownership if it was created by current
* user or session, using the current class.
......@@ -500,8 +723,8 @@ class Stage {
* @throws \LogicException
* If ::claim() has not been previously called.
* @throws \Drupal\package_manager\Exception\StageOwnershipException
* If the current user or session does not own the staging area, or it was
* created by a different class.
* If the current user or session does not own the stage directory, or it
* was created by a different class.
*/
final protected function checkOwnership(): void {
if (empty($this->lock)) {
......@@ -510,7 +733,7 @@ class Stage {
$stored_lock = $this->tempStore->getIfOwner(static::TEMPSTORE_LOCK_KEY);
if ($stored_lock !== $this->lock) {
throw new StageOwnershipException('Stage is not owned by the current user or session.');
throw new StageOwnershipException($this, 'Stage is not owned by the current user or session.');
}
}
......@@ -531,15 +754,37 @@ class Stage {
}
/**
* Returns the directory where staging areas will be created.
* Returns the directory where stage directories will be created.
*
* @return string
* The absolute path of the directory containing the staging areas managed
* by this class.
* The absolute path of the directory containing the stage directories
* managed by this class.
*/
protected function getStagingRoot(): string {
$site_id = $this->configFactory->get('system.site')->get('uuid');
return FileSystem::getOsTemporaryDirectory() . DIRECTORY_SEPARATOR . '.package_manager' . $site_id;
private function getStagingRoot(): string {
// Since the stage root can depend on site settings, store it so that
// things won't break if the settings change during this stage's life
// cycle.
$dir = $this->tempStore->get(self::TEMPSTORE_STAGING_ROOT_KEY);
if (empty($dir)) {
$dir = $this->pathLocator->getStagingRoot();
$this->tempStore->set(self::TEMPSTORE_STAGING_ROOT_KEY, $dir);
}
return $dir;
}
/**
* Determines if the stage directory exists.
*
* @return bool
* TRUE if the directory exists, otherwise FALSE.
*/
public function stageDirectoryExists(): bool {
try {
return is_dir($this->getStageDirectory());
}
catch (\LogicException) {
return FALSE;
}
}
/**
......@@ -559,4 +804,72 @@ class Stage {
return isset($apply_time) && $this->time->getRequestTime() - $apply_time < 3600;
}
/**
* Returns the failure marker message.
*
* @return \Drupal\Core\StringTranslation\TranslatableMarkup
* The translated failure marker message.
*/
protected function getFailureMarkerMessage(): TranslatableMarkup {
return $this->t('Staged changes failed to apply, and the site is in an indeterminate state. It is strongly recommended to restore the code and database from a backup.');
}
/**
* Validates a set of package names.
*
* Package names are considered invalid if they look like Drupal project
* names. The only exceptions to this are platform requirements, like `php`,
* `composer`, or `ext-json`, which are legitimate to Composer.
*
* @param string[] $requirements
* A set of package names (with or without version constraints), as passed
* to ::require().
*
* @throws \InvalidArgumentException
* Thrown if any of the given package names fail basic validation.
*/
protected static function validateRequirements(array $requirements): void {
$version_parser = new VersionParser();
foreach ($requirements as $requirement) {
$parts = explode(':', $requirement, 2);
$name = $parts[0];
if (!preg_match(self::COMPOSER_PLATFORM_PACKAGE_REGEX, $name) && !preg_match(self::COMPOSER_PACKAGE_REGEX, $name)) {
throw new \InvalidArgumentException("Invalid package name '$name'.");
}
if (count($parts) > 1) {
$version_parser->parseConstraints($parts[1]);
}
}
}
/**
* Stores information about the stage when it is destroyed.
*
* @param bool $force
* Whether the stage was force destroyed.
* @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $message
* A message about why the stage was destroyed or null.
*
* @throws \Drupal\Core\TempStore\TempStoreException
*/
protected function storeDestroyInfo(bool $force, ?TranslatableMarkup $message): void {
if (!$message) {
if ($this->tempStore->get(self::TEMPSTORE_CHANGES_APPLIED) === TRUE) {
$message = $this->t('This operation has already been applied.');
}
else {
if ($force) {
$message = $this->t('This operation was canceled by another user.');
}
else {
$message = $this->t('This operation was already canceled.');
}
}
}
[$id] = $this->tempStore->get(static::TEMPSTORE_LOCK_KEY);
$this->tempStore->set(self::TEMPSTORE_DESTROYED_STAGES_INFO_PREFIX . $id, $message);
}
}
<?php
declare(strict_types = 1);
namespace Drupal\package_manager;
use Drupal\package_manager\Event\CollectPathsToExcludeEvent;
use Drupal\package_manager\Event\StatusCheckEvent;
use PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
/**
* Contains helper methods to run status checks on a stage.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not use or interact with
* this trait.
*/
trait StatusCheckTrait {
/**
* Runs a status check for a stage and returns the results, if any.
*
* @param \Drupal\package_manager\StageBase $stage
* The stage to run the status check for.
* @param \Symfony\Contracts\EventDispatcher\EventDispatcherInterface $event_dispatcher
* (optional) The event dispatcher service.
* @param \Drupal\package_manager\PathLocator $path_locator
* (optional) The path locator service.
* @param \PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface $path_factory
* (optional) The path factory service.
*
* @return \Drupal\package_manager\ValidationResult[]
* The results of the status check. If a readiness check was also done,
* its results will be included.
*/
protected function runStatusCheck(StageBase $stage, EventDispatcherInterface $event_dispatcher = NULL, PathLocator $path_locator = NULL, PathFactoryInterface $path_factory = NULL): array {
$event_dispatcher ??= \Drupal::service('event_dispatcher');
$path_locator ??= \Drupal::service(PathLocator::class);
$path_factory ??= \Drupal::service(PathFactoryInterface::class);
try {
$paths_to_exclude_event = new CollectPathsToExcludeEvent($stage, $path_locator, $path_factory);
$event_dispatcher->dispatch($paths_to_exclude_event);
}
catch (\Throwable $throwable) {
$paths_to_exclude_event = $throwable;
}
$event = new StatusCheckEvent($stage, $paths_to_exclude_event);
return $event_dispatcher->dispatch($event)->getResults();
}
}
<?php
declare(strict_types = 1);
namespace Drupal\package_manager;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use PhpTuf\ComposerStager\API\Translation\Service\TranslatorInterface;
use PhpTuf\ComposerStager\API\Translation\Value\TranslatableInterface;
use PhpTuf\ComposerStager\API\Translation\Value\TranslationParametersInterface;
/**
* An adapter for interoperable string translation.
*
* This class is designed to adapt Drupal's style of string translation so it
* can be used with the Symfony-inspired architecture used by Composer Stager.
*
* If this object is cast to a string, it will be translated by Drupal's
* translation system. It will ONLY be translated by Composer Stager if the
* trans() method is explicitly called.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
final class TranslatableStringAdapter extends TranslatableMarkup implements TranslatableInterface, TranslationParametersInterface {
/**
* {@inheritdoc}
*/
public function getAll(): array {
return $this->getArguments();
}
/**
* {@inheritdoc}
*/
public function trans(?TranslatorInterface $translator = NULL, ?string $locale = NULL): string {
// This method is NEVER used by Drupal to translate the underlying string;
// it exists solely for Composer Stager's translation system to
// transparently translate Drupal strings using its own architecture.
return $translator->trans(
$this->getUntranslatedString(),
$this,
// The 'context' option is the closest analogue to the Symfony-inspired
// concept of translation domains.
$this->getOption('context'),
$locale ?? $this->getOption('langcode'),
);
}
}
<?php
declare(strict_types = 1);
namespace Drupal\package_manager;
use Drupal\Core\StringTranslation\TranslationInterface;
use PhpTuf\ComposerStager\API\Translation\Factory\TranslatableFactoryInterface;
use PhpTuf\ComposerStager\API\Translation\Service\DomainOptionsInterface;
use PhpTuf\ComposerStager\API\Translation\Value\TranslatableInterface;
use PhpTuf\ComposerStager\API\Translation\Value\TranslationParametersInterface;
/**
* Creates translatable strings that can interoperate with Composer Stager.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
final class TranslatableStringFactory implements TranslatableFactoryInterface {
/**
* Constructs a TranslatableStringFactory object.
*
* @param \PhpTuf\ComposerStager\API\Translation\Factory\TranslatableFactoryInterface $decorated
* The decorated translatable factory service.
* @param \Drupal\Core\StringTranslation\TranslationInterface $translation
* The string translation service.
*/
public function __construct(
private readonly TranslatableFactoryInterface $decorated,
private readonly TranslationInterface $translation,
) {}
/**
* {@inheritdoc}
*/
public function createDomainOptions(): DomainOptionsInterface {
return $this->decorated->createDomainOptions();
}
/**
* {@inheritdoc}
*/
public function createTranslatableMessage(string $message, ?TranslationParametersInterface $parameters = NULL, ?string $domain = NULL,): TranslatableInterface {
return new TranslatableStringAdapter(
$message,
$parameters?->getAll() ?? [],
// TranslatableMarkup's 'context' option is the closest analogue to the
// $domain parameter.
['context' => $domain ?? ''],
$this->translation,
);
}
/**
* {@inheritdoc}
*/
public function createTranslationParameters(array $parameters = []): TranslationParametersInterface {
return $this->decorated->createTranslationParameters($parameters);
}
}
<?php
declare(strict_types = 1);
namespace Drupal\package_manager;
use Drupal\Component\Assertion\Inspector;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\system\SystemManager;
use PhpTuf\ComposerStager\API\Exception\ExceptionInterface;
/**
* A value object to contain the results of a validation.
*
* @property \Drupal\Core\StringTranslation\TranslatableMarkup[] $messages
*/
class ValidationResult {
final class ValidationResult {
/**
* A succinct summary of the results.
* Creates a ValidationResult object.
*
* @var \Drupal\Core\StringTranslation\TranslatableMarkup
*/
protected $summary;
/**
* The error messages.
* @param int $severity
* The severity of the result. Should be one of the
* SystemManager::REQUIREMENT_* constants.
* @param \Drupal\Core\StringTranslation\TranslatableMarkup[]|string[] $messages
* The result messages.
* @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $summary
* A succinct summary of the result.
* @param bool $assert_translatable
* Whether to assert the messages are translatable. Internal use only.
*
* @var \Drupal\Core\StringTranslation\TranslatableMarkup[]
* @throws \InvalidArgumentException
* Thrown if $messages is empty, or if it has 2 or more items but $summary
* is NULL.
*/
protected $messages;
private function __construct(
public readonly int $severity,
private array $messages,
public readonly ?TranslatableMarkup $summary,
bool $assert_translatable
) {
if ($assert_translatable) {
assert(Inspector::assertAll(fn ($message) => $message instanceof TranslatableMarkup, $messages));
}
if (empty($messages)) {
throw new \InvalidArgumentException('At least one message is required.');
}
if (count($messages) > 1 && !$summary) {
throw new \InvalidArgumentException('If more than one message is provided, a summary is required.');
}
}
/**
* The severity of the result.
*
* @var int
* Implements magic ::__get() method.
*/
protected $severity;
public function __get(string $name): mixed {
return match ($name) {
// The messages must be private so that they cannot be mutated by external
// code, but we want to allow callers to access them in the same way as
// $this->summary and $this->severity.
'messages' => $this->messages,
};
}
/**
* Creates a ValidationResult object.
* Creates an error ValidationResult object from a throwable.
*
* @param int $severity
* The severity of the result. Should be one of the
* SystemManager::REQUIREMENT_* constants.
* @param \Drupal\Core\StringTranslation\TranslatableMarkup[] $messages
* The error messages.
* @param \Throwable $throwable
* The throwable.
* @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $summary
* The errors summary.
*
* @return static
*/
private function __construct(int $severity, array $messages, ?TranslatableMarkup $summary = NULL) {
if (count($messages) > 1 && !$summary) {
throw new \InvalidArgumentException('If more than one message is provided, a summary is required.');
}
$this->summary = $summary;
$this->messages = $messages;
$this->severity = $severity;
public static function createErrorFromThrowable(\Throwable $throwable, ?TranslatableMarkup $summary = NULL): static {
// All Composer Stager exceptions are translatable.
$is_translatable = $throwable instanceof ExceptionInterface;
$message = $is_translatable ? $throwable->getTranslatableMessage() : $throwable->getMessage();
return new static(SystemManager::REQUIREMENT_ERROR, [$message], $summary, $is_translatable);
}
/**
......@@ -61,8 +89,8 @@ class ValidationResult {
*
* @return static
*/
public static function createError(array $messages, ?TranslatableMarkup $summary = NULL): self {
return new static(SystemManager::REQUIREMENT_ERROR, $messages, $summary);
public static function createError(array $messages, ?TranslatableMarkup $summary = NULL): static {
return new static(SystemManager::REQUIREMENT_ERROR, $messages, $summary, TRUE);
}
/**
......@@ -75,39 +103,48 @@ class ValidationResult {
*
* @return static
*/
public static function createWarning(array $messages, ?TranslatableMarkup $summary = NULL): self {
return new static(SystemManager::REQUIREMENT_WARNING, $messages, $summary);
public static function createWarning(array $messages, ?TranslatableMarkup $summary = NULL): static {
return new static(SystemManager::REQUIREMENT_WARNING, $messages, $summary, TRUE);
}
/**
* Gets the summary.
* Returns the overall severity for a set of validation results.
*
* @return \Drupal\Core\StringTranslation\TranslatableMarkup|null
* The summary.
*/
public function getSummary(): ?TranslatableMarkup {
return $this->summary;
}
/**
* Gets the messages.
* @param \Drupal\package_manager\ValidationResult[] $results
* The validation results.
*
* @return \Drupal\Core\StringTranslation\TranslatableMarkup[]
* The error or warning messages.
* @return int
* The overall severity of the results. Will be be one of the
* SystemManager::REQUIREMENT_* constants.
*/
public function getMessages(): array {
return $this->messages;
public static function getOverallSeverity(array $results): int {
foreach ($results as $result) {
if ($result->severity === SystemManager::REQUIREMENT_ERROR) {
return SystemManager::REQUIREMENT_ERROR;
}
}
// If there were no errors, then any remaining results must be warnings.
return $results ? SystemManager::REQUIREMENT_WARNING : SystemManager::REQUIREMENT_OK;
}
/**
* The severity of the result.
* Determines if two validation results are equivalent.
*
* @return int
* Either SystemManager::REQUIREMENT_ERROR or
* SystemManager::REQUIREMENT_WARNING.
* @param self $a
* A validation result.
* @param self $b
* Another validation result.
*
* @return bool
* TRUE if the given validation results have the same severity, summary,
* and messages (in the same order); otherwise FALSE.
*/
public function getSeverity(): int {
return $this->severity;
public static function isEqual(self $a, self $b): bool {
return (
$a->severity === $b->severity &&
strval($a->summary) === strval($b->summary) &&
array_map('strval', $a->messages) === array_map('strval', $b->messages)
);
}
}
<?php
declare(strict_types = 1);
namespace Drupal\package_manager\Validator;
use Drupal\Component\Serialization\Json;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\package_manager\Event\StatusCheckEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Drupal\package_manager\ComposerInspector;
use Drupal\package_manager\Event\PreApplyEvent;
use Drupal\package_manager\Event\PreCreateEvent;
use Drupal\package_manager\Event\PreOperationStageEvent;
use Drupal\package_manager\PathLocator;
/**
* Validates the list of packages that are allowed to scaffold files.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
final class AllowedScaffoldPackagesValidator implements EventSubscriberInterface {
use StringTranslationTrait;
/**
* Constructs a AllowedScaffoldPackagesValidator object.
*
* @param \Drupal\package_manager\ComposerInspector $composerInspector
* The Composer inspector service.
* @param \Drupal\package_manager\PathLocator $pathLocator
* The path locator service.
*/
public function __construct(
private readonly ComposerInspector $composerInspector,
private readonly PathLocator $pathLocator,
) {}
/**
* Validates that only the implicitly allowed packages can use scaffolding.
*/
public function validate(PreOperationStageEvent $event): void {
$stage = $event->stage;
$path = $event instanceof PreApplyEvent
? $stage->getStageDirectory()
: $this->pathLocator->getProjectRoot();
// @see https://www.drupal.org/docs/develop/using-composer/using-drupals-composer-scaffold
$implicitly_allowed_packages = [
"drupal/legacy-scaffold-assets",
"drupal/core",
];
$extra = Json::decode($this->composerInspector->getConfig('extra', $path . '/composer.json'));
$allowed_packages = $extra['drupal-scaffold']['allowed-packages'] ?? [];
$extra_packages = array_diff($allowed_packages, $implicitly_allowed_packages);
if (!empty($extra_packages)) {
$event->addError(
array_map($this->t(...), $extra_packages),
$this->t('Any packages other than the implicitly allowed packages are not allowed to scaffold files. See <a href=":url">the scaffold documentation</a> for more information.', [
':url' => 'https://www.drupal.org/docs/develop/using-composer/using-drupals-composer-scaffold',
])
);
}
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents() : array {
return [
StatusCheckEvent::class => 'validate',
PreCreateEvent::class => 'validate',
PreApplyEvent::class => 'validate',
];
}
}
<?php
declare(strict_types = 1);
namespace Drupal\package_manager\Validator;
use Drupal\package_manager\Event\PreApplyEvent;
use Drupal\package_manager\Event\PreCreateEvent;
use Drupal\package_manager\Event\PreOperationStageEvent;
use Drupal\package_manager\Event\PreRequireEvent;
use Drupal\package_manager\Event\StatusCheckEvent;
/**
* Provides helper methods for base requirement validators.
*
* This trait should only be used validators that check base requirements,
* which means they run before
* \Drupal\package_manager\Validator\BaseRequirementsFulfilledValidator.
*
* Validators which use this trait should NOT stop event propagation.
*
* @see \Drupal\package_manager\Validator\BaseRequirementsFulfilledValidator
*/
trait BaseRequirementValidatorTrait {
/**
* Validates base requirements.
*
* @param \Drupal\package_manager\Event\PreOperationStageEvent $event
* The event being handled.
*/
abstract public function validate(PreOperationStageEvent $event): void;
/**
* Implements EventSubscriberInterface::getSubscribedEvents().
*/
public static function getSubscribedEvents(): array {
// Always run before the BaseRequirementsFulfilledValidator.
// @see \Drupal\package_manager\Validator\BaseRequirementsFulfilledValidator
$priority = BaseRequirementsFulfilledValidator::PRIORITY + 10;
return [
PreCreateEvent::class => ['validate', $priority],
PreRequireEvent::class => ['validate', $priority],
PreApplyEvent::class => ['validate', $priority],
StatusCheckEvent::class => ['validate', $priority],
];
}
}
<?php
namespace Drupal\package_manager\Validator;
use Drupal\package_manager\Event\PreApplyEvent;
use Drupal\package_manager\Event\PreCreateEvent;
use Drupal\package_manager\Event\PreOperationStageEvent;
use Drupal\package_manager\Event\PreRequireEvent;
use Drupal\package_manager\Event\StatusCheckEvent;
use Drupal\system\SystemManager;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Validates that base requirements do not have any errors.
*
* Base requirements are the sorts of things that must be in a good state for
* Package Manager to be usable. For example, Composer must be available and
* usable; certain paths of the file system must be writable; the current site
* cannot be part of a multisite, and so on.
*
* This validator simply stops event propagation if any of the validators before
* it have added error results. Validators that check base requirements should
* run before this validator (they can use
* \Drupal\package_manager\Validator\BaseRequirementValidatorTrait to make this
* easier). To ensure that all base requirement errors are shown to the user, no
* base requirement validator should stop event propagation itself.
*
* Base requirement validators should not depend on each other or assume that
* Composer is usable in the current environment.
*
* @see \Drupal\package_manager\Validator\BaseRequirementValidatorTrait
*/
final class BaseRequirementsFulfilledValidator implements EventSubscriberInterface {
/**
* The priority of this validator.
*
* @see ::getSubscribedEvents()
*
* @var int
*/
public const PRIORITY = 200;
/**
* Validates that base requirements are fulfilled.
*
* @param \Drupal\package_manager\Event\PreOperationStageEvent $event
* The event.
*/
public function validate(PreOperationStageEvent $event): void {
// If there are any errors from the validators which ran before this one,
// base requirements are not fulfilled and we should stop any further
// validators from running.
if ($event->getResults(SystemManager::REQUIREMENT_ERROR)) {
$event->stopPropagation();
}
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
return [
PreCreateEvent::class => ['validate', self::PRIORITY],
PreRequireEvent::class => ['validate', self::PRIORITY],
PreApplyEvent::class => ['validate', self::PRIORITY],
StatusCheckEvent::class => ['validate', self::PRIORITY],
];
}
}
<?php
namespace Drupal\package_manager\Validator;
use Composer\Semver\Comparator;
use Drupal\package_manager\Event\PreCreateEvent;
use Drupal\package_manager\Event\PreOperationStageEvent;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\StringTranslation\TranslationInterface;
use PhpTuf\ComposerStager\Domain\Process\OutputCallbackInterface;
use PhpTuf\ComposerStager\Domain\Process\Runner\ComposerRunnerInterface;
use PhpTuf\ComposerStager\Exception\ExceptionInterface;
/**
* Validates the Composer executable is the correct version.
*/
class ComposerExecutableValidator implements PreOperationStageValidatorInterface, OutputCallbackInterface {
use StringTranslationTrait;
/**
* The minimum required version of Composer.
*
* @var string
*/
public const MINIMUM_COMPOSER_VERSION = '2.2.4';
/**
* The Composer runner.
*
* @var \PhpTuf\ComposerStager\Domain\Process\Runner\ComposerRunnerInterface
*/
protected $composer;
/**
* The detected version of Composer.
*
* @var string
*/
protected $version;
/**
* Constructs a ComposerExecutableValidator object.
*
* @param \PhpTuf\ComposerStager\Domain\Process\Runner\ComposerRunnerInterface $composer
* The Composer runner.
* @param \Drupal\Core\StringTranslation\TranslationInterface $translation
* The translation service.
*/
public function __construct(ComposerRunnerInterface $composer, TranslationInterface $translation) {
$this->composer = $composer;
$this->setStringTranslation($translation);
}
/**
* {@inheritdoc}
*/
public function validateStagePreOperation(PreOperationStageEvent $event): void {
try {
$this->composer->run(['--version'], $this);
}
catch (ExceptionInterface $e) {
$event->addError([
$e->getMessage(),
]);
return;
}
if ($this->version) {
if (Comparator::lessThan($this->version, static::MINIMUM_COMPOSER_VERSION)) {
$event->addError([
$this->t('Composer @minimum_version or later is required, but version @detected_version was detected.', [
'@minimum_version' => static::MINIMUM_COMPOSER_VERSION,
'@detected_version' => $this->version,
]),
]);
}
}
else {
$event->addError([
$this->t('The Composer version could not be detected.'),
]);
}
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents() {
return [
PreCreateEvent::class => 'validateStagePreOperation',
];
}
/**
* {@inheritdoc}
*/
public function __invoke(string $type, string $buffer): void {
$matched = [];
// Search for a semantic version number and optional stability flag.
if (preg_match('/([0-9]+\.?){3}-?((alpha|beta|rc)[0-9]*)?/i', $buffer, $matched)) {
$this->version = $matched[0];
}
}
}
<?php
declare(strict_types = 1);
namespace Drupal\package_manager\Validator;
use Composer\Semver\Semver;
use Composer\Semver\VersionParser;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\package_manager\ComposerInspector;
use Drupal\package_manager\Event\PreRequireEvent;
use Drupal\package_manager\PathLocator;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Checks that the (about to be) installed packages meet the minimum stability.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
final class ComposerMinimumStabilityValidator implements EventSubscriberInterface {
use StringTranslationTrait;
/**
* Constructs a ComposerMinimumStabilityValidator object.
*
* @param \Drupal\package_manager\PathLocator $pathLocator
* The path locator service.
* @param \Drupal\package_manager\ComposerInspector $inspector
* The Composer inspector service.
*/
public function __construct(
private readonly PathLocator $pathLocator,
private readonly ComposerInspector $inspector
) {}
/**
* Validates composer minimum stability.
*
* @param \Drupal\package_manager\Event\PreRequireEvent $event
* The stage event.
*/
public function validate(PreRequireEvent $event): void {
$dir = $this->pathLocator->getProjectRoot();
$minimum_stability = $this->inspector->getConfig('minimum-stability', $dir);
$requested_packages = array_merge($event->getDevPackages(), $event->getRuntimePackages());
foreach ($requested_packages as $package_name => $version) {
// In the root composer.json, a stability flag can also be specified. They
// take the form @code constraint@stability @endcode. A stability flag
// allow the project owner to deviate from the minimum-stability setting.
// @see https://getcomposer.org/doc/04-schema.md#package-links
// @see \Composer\Package\Loader\RootPackageLoader::extractStabilityFlags()
if (str_contains($version, '@')) {
continue;
}
$stability = VersionParser::parseStability($version);
// Because drupal/core prefers to not depend on composer/composer we need
// to compare two versions that are identical except for stability to
// determine if the package stability is less that the minimum stability.
if (Semver::satisfies("1.0.0-$stability", "< 1.0.0-$minimum_stability")) {
$event->addError([
$this->t("<code>@package_name</code>'s requested version @package_version is less stable (@package_stability) than the minimum stability (@minimum_stability) required in @file.",
[
'@package_name' => $package_name,
'@package_version' => $version,
'@package_stability' => $stability,
'@minimum_stability' => $minimum_stability,
'@file' => $this->pathLocator->getProjectRoot() . '/composer.json',
]
),
]);
}
}
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
return [
PreRequireEvent::class => 'validate',
];
}
}
<?php
declare(strict_types = 1);
namespace Drupal\package_manager\Validator;
use Composer\Semver\Semver;
use Drupal\Component\Serialization\Json;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Url;
use Drupal\package_manager\ComposerInspector;
use Drupal\package_manager\Event\PreApplyEvent;
use Drupal\package_manager\Event\PreCreateEvent;
use Drupal\package_manager\Event\PreOperationStageEvent;
use Drupal\package_manager\Event\StatusCheckEvent;
use Drupal\package_manager\PathLocator;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Validates the configuration of the cweagans/composer-patches plugin.
*
* To ensure that applied patches remain consistent between the active and
* stage directories, the following rules are enforced if the patcher is
* installed:
* - It must be installed in both places, or in neither of them. It can't, for
* example, be installed in the active directory but not the stage directory
* (or vice-versa).
* - It must be one of the project's direct runtime or dev dependencies.
* - It cannot be installed or removed by Package Manager. In other words, it
* must be added to the project at the command line by someone technical
* enough to install and configure it properly.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
final class ComposerPatchesValidator implements EventSubscriberInterface {
use StringTranslationTrait;
/**
* The name of the plugin being analyzed.
*
* @var string
*/
private const PLUGIN_NAME = 'cweagans/composer-patches';
/**
* Constructs a ComposerPatchesValidator object.
*
* @param \Drupal\Core\Extension\ModuleHandlerInterface $moduleHandler
* The module handler service.
* @param \Drupal\package_manager\ComposerInspector $composerInspector
* The Composer inspector service.
* @param \Drupal\package_manager\PathLocator $pathLocator
* The path locator service.
*/
public function __construct(
private readonly ModuleHandlerInterface $moduleHandler,
private readonly ComposerInspector $composerInspector,
private readonly PathLocator $pathLocator
) {}
/**
* Validates the status of the patcher plugin.
*
* @param \Drupal\package_manager\Event\PreOperationStageEvent $event
* The event object.
*/
public function validate(PreOperationStageEvent $event): void {
$messages = [];
[$plugin_installed_in_active, $is_active_root_requirement, $active_configuration_ok] = $this->computePatcherStatus($this->pathLocator->getProjectRoot());
if ($event instanceof PreApplyEvent) {
[$plugin_installed_in_stage, $is_stage_root_requirement, $stage_configuration_ok] = $this->computePatcherStatus($event->stage->getStageDirectory());
$has_staged_update = TRUE;
}
else {
// No staged update exists.
$has_staged_update = FALSE;
}
// If there's a staged update and the patcher has been installed or removed
// in the stage directory, that's a problem.
if ($has_staged_update && $plugin_installed_in_active !== $plugin_installed_in_stage) {
if ($plugin_installed_in_stage) {
$message = $this->t('It cannot be installed by Package Manager.');
}
else {
$message = $this->t('It cannot be removed by Package Manager.');
}
$messages[] = $this->createErrorMessage($message, 'package-manager-faq-composer-patches-installed-or-removed');
}
// If the patcher is not listed in the runtime or dev dependencies, that's
// an error as well.
if (($plugin_installed_in_active && !$is_active_root_requirement) || ($has_staged_update && $plugin_installed_in_stage && !$is_stage_root_requirement)) {
$messages[] = $this->createErrorMessage($this->t('It must be a root dependency.'), 'package-manager-faq-composer-patches-not-a-root-dependency');
}
// If the plugin is misconfigured in either the active or stage directories,
// flag an error.
if (($plugin_installed_in_active && !$active_configuration_ok) || ($has_staged_update && $plugin_installed_in_stage && !$stage_configuration_ok)) {
$messages[] = $this->t('The <code>composer-exit-on-patch-failure</code> key is not set to <code>true</code> in the <code>extra</code> section of <code>composer.json</code>.');
}
if ($messages) {
$summary = $this->t("Problems detected related to the Composer plugin <code>@plugin</code>.", [
'@plugin' => static::PLUGIN_NAME,
]);
$event->addError($messages, $summary);
}
}
/**
* Appends a link to online help to an error message.
*
* @param \Drupal\Core\StringTranslation\TranslatableMarkup $message
* The error message.
* @param string $fragment
* The fragment of the online help to link to.
*
* @return \Drupal\Core\StringTranslation\TranslatableMarkup
* The final, translated error message.
*/
private function createErrorMessage(TranslatableMarkup $message, string $fragment): TranslatableMarkup {
if ($this->moduleHandler->moduleExists('help')) {
$url = Url::fromRoute('help.page', ['name' => 'package_manager'])
->setOption('fragment', $fragment)
->toString();
return $this->t('@message See <a href=":url">the help page</a> for information on how to resolve the problem.', [
'@message' => $message,
':url' => $url,
]);
}
return $message;
}
/**
* Computes the status of the patcher plugin in a particular directory.
*
* @param string $working_dir
* The directory in which to run Composer.
*
* @return bool[]
* An indexed array containing three booleans, in order:
* - Whether the patcher plugin is installed.
* - Whether the patcher plugin is a root requirement in composer.json (in
* either the runtime or dev dependencies).
* - Whether the `composer-exit-on-patch-failure` flag is set in the `extra`
* section of composer.json.
*/
private function computePatcherStatus(string $working_dir): array {
$list = $this->composerInspector->getInstalledPackagesList($working_dir);
$installed_version = $list[static::PLUGIN_NAME]?->version;
$info = $this->composerInspector->getRootPackageInfo($working_dir);
$is_root_requirement = array_key_exists(static::PLUGIN_NAME, $info['requires'] ?? []) || array_key_exists(static::PLUGIN_NAME, $info['devRequires'] ?? []);
// The 2.x version of the plugin always exits with an error if a patch can't
// be applied.
if ($installed_version && Semver::satisfies($installed_version, '^2')) {
$exit_on_failure = TRUE;
}
else {
$extra = Json::decode($this->composerInspector->getConfig('extra', $working_dir));
$exit_on_failure = $extra['composer-exit-on-patch-failure'] ?? FALSE;
}
return [
is_string($installed_version),
$is_root_requirement,
$exit_on_failure,
];
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
return [
PreCreateEvent::class => 'validate',
PreApplyEvent::class => 'validate',
StatusCheckEvent::class => 'validate',
];
}
}
<?php
declare(strict_types = 1);
namespace Drupal\package_manager\Validator;
use Composer\Semver\Semver;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\package_manager\ComposerInspector;
use Drupal\package_manager\Event\PreApplyEvent;
use Drupal\package_manager\Event\PreCreateEvent;
use Drupal\package_manager\Event\PreOperationStageEvent;
use Drupal\package_manager\Event\StatusCheckEvent;
use Drupal\package_manager\PathLocator;
use PhpTuf\ComposerStager\API\Exception\RuntimeException;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Validates the allowed Composer plugins, both in active and stage.
*
* Composer plugins can make far-reaching changes on the filesystem. That is why
* they can cause Package Manager (more specifically the infrastructure it uses:
* php-tuf/composer-stager) to not work reliably; potentially even break a site!
*
* This validator restricts the use of Composer plugins:
* - Allowing all plugins to run indiscriminately is discouraged by Composer,
* but disallowed by this module (it is too risky):
* @code config.allowed-plugins = true @endcode is forbidden.
* - Installed Composer plugins that are not allowed (in composer.json's
* @code config.allowed-plugins @endcode) are not executed by Composer, so
* these are safe.
* - Installed Composer plugins that are allowed need to be either explicitly
* supported by this validator (they may still need their own validation to
* ensure their configuration is safe, for example Drupal core's vendor
* hardening plugin), or explicitly trusted by adding it to the
* @code package_manager.settings @endcode configuration's
* @code additional_trusted_composer_plugins @endcode list.
*
* @todo Determine how other Composer plugins will be supported in
* https://drupal.org/i/3339417.
*
* @see https://getcomposer.org/doc/04-schema.md#type
* @see https://getcomposer.org/doc/articles/plugins.md
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
final class ComposerPluginsValidator implements EventSubscriberInterface {
use StringTranslationTrait;
/**
* Composer plugins known to modify other packages, but are validated.
*
* (The validation guarantees they are safe to use.)
*
* @var string[]
* Keys are Composer plugin package names, values are version constraints
* for those plugins that this validator explicitly supports.
*/
private const SUPPORTED_PLUGINS_THAT_DO_MODIFY = [
// cSpell:disable
// @see \Drupal\package_manager\Validator\ComposerPatchesValidator
'cweagans/composer-patches' => '^1.7.3 || ^2',
// @see \Drupal\package_manager\PathExcluder\VendorHardeningExcluder
'drupal/core-vendor-hardening' => '*',
'php-http/discovery' => '*',
// cSpell:enable
];
/**
* Composer plugins known to NOT modify other packages.
*
* @var string[]
* Keys are Composer plugin package names, values are version constraints
* for those plugins that this validator explicitly supports.
*/
private const SUPPORTED_PLUGINS_THAT_DO_NOT_MODIFY = [
// cSpell:disable
'composer/installers' => '^2.0',
'dealerdirect/phpcodesniffer-composer-installer' => '^0.7.1 || ^1.0.0',
'drupal/core-composer-scaffold' => '*',
'drupal/core-project-message' => '*',
'phpstan/extension-installer' => '^1.1',
// cSpell:enable
PhpTufValidator::PLUGIN_NAME => '^1',
];
/**
* The additional trusted Composer plugin package names.
*
* Note: these are normalized package names.
*
* @var string[]
* Keys are package names, values are version constraints.
*/
private array $additionalTrustedComposerPlugins;
/**
* Constructs a new ComposerPluginsValidator.
*
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The config factory.
* @param \Drupal\package_manager\ComposerInspector $inspector
* The Composer inspector service.
* @param \Drupal\package_manager\PathLocator $pathLocator
* The path locator service.
*/
public function __construct(
ConfigFactoryInterface $config_factory,
private readonly ComposerInspector $inspector,
private readonly PathLocator $pathLocator,
) {
$settings = $config_factory->get('package_manager.settings');
$this->additionalTrustedComposerPlugins = array_fill_keys(
array_map(
[__CLASS__, 'normalizePackageName'],
$settings->get('additional_trusted_composer_plugins')
),
// For now, additional_trusted_composer_plugins cannot specify a version
// constraint.
'*'
);
}
/**
* Normalizes a package name.
*
* @param string $package_name
* A package name.
*
* @return string
* The normalized package name.
*/
private static function normalizePackageName(string $package_name): string {
return strtolower($package_name);
}
/**
* Validates the allowed Composer plugins, both in active and stage.
*/
public function validate(PreOperationStageEvent $event): void {
$stage = $event->stage;
// When about to copy the changes from the stage directory to the active
// directory, use the stage directory's composer instead of the active.
// Because composer plugins may be added or removed; the only thing that
// matters is the set of composer plugins that *will* apply — if a composer
// plugin is being removed, that's fine.
$dir = $event instanceof PreApplyEvent
? $stage->getStageDirectory()
: $this->pathLocator->getProjectRoot();
try {
$allowed_plugins = $this->inspector->getAllowPluginsConfig($dir);
}
catch (RuntimeException $exception) {
$event->addErrorFromThrowable($exception, $this->t('Unable to determine Composer <code>allow-plugins</code> setting.'));
return;
}
if ($allowed_plugins === TRUE) {
$event->addError([$this->t('All composer plugins are allowed because <code>config.allow-plugins</code> is configured to <code>true</code>. This is an unacceptable security risk.')]);
return;
}
// TRICKY: additional trusted Composer plugins is listed first, to allow
// site owners who know what they're doing to use unsupported versions of
// supported Composer plugins.
$trusted_plugins = $this->additionalTrustedComposerPlugins
+ self::SUPPORTED_PLUGINS_THAT_DO_MODIFY
+ self::SUPPORTED_PLUGINS_THAT_DO_NOT_MODIFY;
assert(is_array($allowed_plugins));
// Only packages with `true` as a value are actually executed by Composer.
$allowed_plugins = array_keys(array_filter($allowed_plugins));
// The keys are normalized package names, and the values are the original,
// non-normalized package names.
$allowed_plugins = array_combine(
array_map([__CLASS__, 'normalizePackageName'], $allowed_plugins),
$allowed_plugins
);
$installed_packages = $this->inspector->getInstalledPackagesList($dir);
// Determine which plugins are both trusted by us, AND allowed by Composer's
// configuration.
$supported_plugins = array_intersect_key($allowed_plugins, $trusted_plugins);
// Create an array whose keys are the names of those plugins, and the values
// are their installed versions.
$supported_plugins_installed_versions = array_combine(
$supported_plugins,
array_map(
fn (string $name): ?string => $installed_packages[$name]?->version,
$supported_plugins
)
);
// Find the plugins whose installed versions aren't in the supported range.
$unsupported_installed_versions = array_filter(
$supported_plugins_installed_versions,
fn (?string $version, string $name): bool => $version && !Semver::satisfies($version, $trusted_plugins[$name]),
ARRAY_FILTER_USE_BOTH
);
$untrusted_plugins = array_diff_key($allowed_plugins, $trusted_plugins);
$messages = array_map(
fn (string $raw_name) => $this->t('<code>@name</code>', ['@name' => $raw_name]),
$untrusted_plugins
);
foreach ($unsupported_installed_versions as $name => $installed_version) {
$messages[] = $this->t("<code>@name</code> is supported, but only version <code>@supported_version</code>, found <code>@installed_version</code>.", [
'@name' => $name,
'@supported_version' => $trusted_plugins[$name],
'@installed_version' => $installed_version,
]);
}
if ($messages) {
$summary = $this->formatPlural(
count($messages),
'An unsupported Composer plugin was detected.',
'Unsupported Composer plugins were detected.',
);
$event->addError($messages, $summary);
}
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
return [
PreCreateEvent::class => 'validate',
PreApplyEvent::class => 'validate',
StatusCheckEvent::class => 'validate',
];
}
}
<?php
namespace Drupal\package_manager\Validator;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\StringTranslation\TranslationInterface;
use Drupal\package_manager\Event\PreCreateEvent;
use Drupal\package_manager\Event\PreOperationStageEvent;
/**
* Validates certain Composer settings.
*/
class ComposerSettingsValidator implements PreOperationStageValidatorInterface {
use StringTranslationTrait;
/**
* Constructs a ComposerSettingsValidator object.
*
* @param \Drupal\Core\StringTranslation\TranslationInterface $translation
* The string translation service.
*/
public function __construct(TranslationInterface $translation) {
$this->setStringTranslation($translation);
}
/**
* {@inheritdoc}
*/
public function validateStagePreOperation(PreOperationStageEvent $event): void {
$config = $event->getStage()
->getActiveComposer()
->getComposer()
->getConfig();
if ($config->get('secure-http') !== TRUE) {
$event->addError([
$this->t('HTTPS must be enabled for Composer downloads. See <a href=":url">the Composer documentation</a> for more information.', [
':url' => 'https://getcomposer.org/doc/06-config.md#secure-http',
]),
]);
}
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents() {
return [
PreCreateEvent::class => 'validateStagePreOperation',
];
}
}
<?php
declare(strict_types = 1);
namespace Drupal\package_manager\Validator;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Url;
use Drupal\package_manager\ComposerInspector;
use Drupal\package_manager\Event\PreApplyEvent;
use Drupal\package_manager\Event\PreOperationStageEvent;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\package_manager\PathLocator;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Validates the project can be used by the Composer Inspector.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
final class ComposerValidator implements EventSubscriberInterface {
use BaseRequirementValidatorTrait;
use StringTranslationTrait;
/**
* Constructs a ComposerExecutableValidator object.
*
* @param \Drupal\package_manager\ComposerInspector $composerInspector
* The Composer inspector service.
* @param \Drupal\package_manager\PathLocator $pathLocator
* The path locator service.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $moduleHandler
* The module handler service.
*/
public function __construct(
private readonly ComposerInspector $composerInspector,
private readonly PathLocator $pathLocator,
private readonly ModuleHandlerInterface $moduleHandler,
) {}
/**
* Validates that the Composer executable is the correct version.
*/
public function validate(PreOperationStageEvent $event): void {
// If we can't stat processes, there's nothing else we can possibly do here.
// @see \Symfony\Component\Process\Process::__construct()
if (!\function_exists('proc_open')) {
$message = $this->t('Composer cannot be used because the <code>proc_open()</code> function is disabled.');
if ($this->moduleHandler->moduleExists('help')) {
$message = $this->t('@message See <a href=":package-manager-help">the help page</a> for information on how to resolve the problem.', [
':package-manager-help' => self::getHelpUrl('package-manager-composer-related-faq'),
]);
}
$event->addError([$message]);
return;
}
$messages = [];
$dir = $event instanceof PreApplyEvent
? $event->stage->getStageDirectory()
: $this->pathLocator->getProjectRoot();
try {
$this->composerInspector->validate($dir);
}
catch (\Throwable $e) {
if ($this->moduleHandler->moduleExists('help')) {
$message = $this->t('@message See <a href=":package-manager-help">the help page</a> for information on how to resolve the problem.', [
'@message' => $e->getMessage(),
':package-manager-help' => self::getHelpUrl('package-manager-composer-related-faq'),
]);
$event->addError([$message]);
}
else {
$event->addErrorFromThrowable($e);
}
return;
}
$settings = [];
foreach (['disable-tls', 'secure-http'] as $key) {
try {
$settings[$key] = json_decode($this->composerInspector->getConfig($key, $dir));
}
catch (\Throwable $e) {
$event->addErrorFromThrowable($e, $this->t('Unable to determine Composer <code>@key</code> setting.', [
'@key' => $key,
]));
return;
}
}
// If disable-tls is enabled, it overrides secure-http and sets its value to
// FALSE, even if secure-http is set to TRUE explicitly.
if ($settings['disable-tls'] === TRUE) {
$message = $this->t('TLS must be enabled for HTTPS Composer downloads.');
// If the Help module is installed, link to our help page, which displays
// the commands for configuring Composer correctly. Otherwise, direct
// users straight to the Composer documentation, which is a little less
// helpful.
if ($this->moduleHandler->moduleExists('help')) {
$messages[] = $this->t('@message See <a href=":url">the help page</a> for more information on how to configure Composer to download packages securely.', [
'@message' => $message,
':url' => self::getHelpUrl('package-manager-requirements'),
]);
}
else {
$messages[] = $this->t('@message See <a href=":url">the Composer documentation</a> for more information.', [
'@message' => $message,
':url' => 'https://getcomposer.org/doc/06-config.md#disable-tls',
]);
}
$messages[] = $this->t('You should also check the value of <code>secure-http</code> and make sure that it is set to <code>true</code> or not set at all.');
}
elseif ($settings['secure-http'] !== TRUE) {
$message = $this->t('HTTPS must be enabled for Composer downloads.');
if ($this->moduleHandler->moduleExists('help')) {
$messages[] = $this->t('@message See <a href=":url">the help page</a> for more information on how to configure Composer to download packages securely.', [
'@message' => $message,
':url' => self::getHelpUrl('package-manager-requirements'),
]);
}
else {
$messages[] = $this->t('@message See <a href=":url">the Composer documentation</a> for more information.', [
'@message' => $message,
':url' => 'https://getcomposer.org/doc/06-config.md#secure-http',
]);
}
}
if ($messages) {
$event->addError($messages, $this->t("Composer settings don't satisfy Package Manager's requirements."));
}
}
/**
* Returns a URL to a specific fragment of Package Manager's online help.
*
* @param string $fragment
* The fragment to link to.
*
* @return string
* A URL to Package Manager's online help.
*/
private static function getHelpUrl(string $fragment): string {
return Url::fromRoute('help.page', ['name' => 'package_manager'])
->setOption('fragment', $fragment)
->toString();
}
}
<?php
declare(strict_types = 1);
namespace Drupal\package_manager\Validator;
use Drupal\package_manager\Event\PreCreateEvent;
use Drupal\package_manager\Event\PreOperationStageEvent;
use Drupal\Component\FileSystem\FileSystem;
use Drupal\Component\Utility\Bytes;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\StringTranslation\TranslationInterface;
use Drupal\package_manager\PathLocator;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Validates that there is enough free disk space to do staging operations.
* Validates that there is enough free disk space to do stage operations.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
class DiskSpaceValidator implements PreOperationStageValidatorInterface {
class DiskSpaceValidator implements EventSubscriberInterface {
use BaseRequirementValidatorTrait;
use StringTranslationTrait;
/**
* The path locator service.
*
* @var \Drupal\package_manager\PathLocator
*/
protected $pathLocator;
/**
* Constructs a DiskSpaceValidator object.
*
* @param \Drupal\package_manager\PathLocator $path_locator
* @param \Drupal\package_manager\PathLocator $pathLocator
* The path locator service.
* @param \Drupal\Core\StringTranslation\TranslationInterface $translation
* The translation service.
*/
public function __construct(PathLocator $path_locator, TranslationInterface $translation) {
$this->pathLocator = $path_locator;
$this->setStringTranslation($translation);
public function __construct(private readonly PathLocator $pathLocator) {
}
/**
......@@ -63,7 +59,7 @@ class DiskSpaceValidator implements PreOperationStageValidatorInterface {
* @param string $path
* The path to check.
*
* @return array
* @return mixed[]
* The statistics for the path.
*
* @throws \RuntimeException
......@@ -96,9 +92,9 @@ class DiskSpaceValidator implements PreOperationStageValidatorInterface {
}
/**
* {@inheritdoc}
* Validates that there is enough free disk space to do stage operations.
*/
public function validateStagePreOperation(PreOperationStageEvent $event): void {
public function validate(PreOperationStageEvent $event): void {
$root_path = $this->pathLocator->getProjectRoot();
$vendor_path = $this->pathLocator->getVendorDirectory();
$messages = [];
......@@ -138,7 +134,7 @@ class DiskSpaceValidator implements PreOperationStageValidatorInterface {
if ($messages) {
$summary = count($messages) > 1
? $this->t("There is not enough disk space to create a staging area.")
? $this->t("There is not enough disk space to create a stage directory.")
: NULL;
$event->addError($messages, $summary);
}
......@@ -154,13 +150,4 @@ class DiskSpaceValidator implements PreOperationStageValidatorInterface {
return FileSystem::getOsTemporaryDirectory();
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents() {
return [
PreCreateEvent::class => 'validateStagePreOperation',
];
}
}
<?php
declare(strict_types = 1);
namespace Drupal\package_manager\Validator;
use Drupal\Core\Extension\ExtensionDiscovery;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\package_manager\Event\PreApplyEvent;
use Drupal\package_manager\PathLocator;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Validates the stage does not have duplicate info.yml not present in active.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
final class DuplicateInfoFileValidator implements EventSubscriberInterface {
use StringTranslationTrait;
/**
* Constructs a DuplicateInfoFileValidator object.
*
* @param \Drupal\package_manager\PathLocator $pathLocator
* The path locator service.
*/
public function __construct(private readonly PathLocator $pathLocator) {
}
/**
* Validates the stage does not have duplicate info.yml not present in active.
*/
public function validate(PreApplyEvent $event): void {
$active_dir = $this->pathLocator->getProjectRoot();
$stage_dir = $event->stage->getStageDirectory();
$active_info_files = $this->findInfoFiles($active_dir);
$stage_info_files = $this->findInfoFiles($stage_dir);
foreach ($stage_info_files as $stage_info_file => $stage_info_count) {
if (isset($active_info_files[$stage_info_file])) {
// Check if stage directory has more info.yml files matching
// $stage_info_file than in the active directory.
if ($stage_info_count > $active_info_files[$stage_info_file]) {
$event->addError([
$this->t('The stage directory has @stage_count instances of @stage_info_file as compared to @active_count in the active directory. This likely indicates that a duplicate extension was installed.', [
'@stage_info_file' => $stage_info_file,
'@stage_count' => $stage_info_count,
'@active_count' => $active_info_files[$stage_info_file],
]),
]);
}
}
// Check if stage directory has two or more info.yml files matching
// $stage_info_file which are not in active directory.
elseif ($stage_info_count > 1) {
$event->addError([
$this->t('The stage directory has @stage_count instances of @stage_info_file. This likely indicates that a duplicate extension was installed.', [
'@stage_info_file' => $stage_info_file,
'@stage_count' => $stage_info_count,
]),
]);
}
}
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
return [
PreApplyEvent::class => 'validate',
];
}
/**
* Recursively finds info.yml files in a directory.
*
* @param string $dir
* The path of the directory to check.
*
* @return int[]
* Array of count of info.yml files in the directory keyed by file name.
*/
private function findInfoFiles(string $dir): array {
// Use the official extension discovery mechanism, but tweak it, because by
// default it resolves duplicates.
// @see \Drupal\Core\Extension\ExtensionDiscovery::process()
$duplicate_aware_extension_discovery = new class($dir, FALSE, []) extends ExtensionDiscovery {
/**
* {@inheritdoc}
*/
protected function process(array $all_files) {
// Unlike parent implementation: no processing, to retain duplicates.
return $all_files;
}
};
// Scan all 4 extension types, explicitly ignoring tests.
$extension_info_files = array_merge(
array_keys($duplicate_aware_extension_discovery->scan('module', FALSE)),
array_keys($duplicate_aware_extension_discovery->scan('theme', FALSE)),
array_keys($duplicate_aware_extension_discovery->scan('profile', FALSE)),
array_keys($duplicate_aware_extension_discovery->scan('theme_engine', FALSE)),
);
$info_files = [];
foreach ($extension_info_files as $info_file) {
$file_name = basename($info_file);
$info_files[$file_name] = ($info_files[$file_name] ?? 0) + 1;
}
return $info_files;
}
}
<?php
declare(strict_types = 1);
namespace Drupal\package_manager\Validator;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Extension\ThemeHandlerInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\package_manager\ComposerInspector;
use Drupal\package_manager\Event\PreApplyEvent;
use Drupal\package_manager\PathLocator;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Validates no enabled Drupal extensions are removed from the stage directory.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
final class EnabledExtensionsValidator implements EventSubscriberInterface {
use StringTranslationTrait;
/**
* Constructs an EnabledExtensionsValidator object.
*
* @param \Drupal\package_manager\PathLocator $pathLocator
* The path locator service.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $moduleHandler
* The module handler service.
* @param \Drupal\package_manager\ComposerInspector $composerInspector
* The Composer inspector service.
* @param \Drupal\Core\Extension\ThemeHandlerInterface $themeHandler
* The theme handler service.
*/
public function __construct(
private readonly PathLocator $pathLocator,
private readonly ModuleHandlerInterface $moduleHandler,
private readonly ComposerInspector $composerInspector,
private readonly ThemeHandlerInterface $themeHandler
) {}
/**
* Validates that no enabled Drupal extensions have been removed.
*
* @param \Drupal\package_manager\Event\PreApplyEvent $event
* The event object.
*/
public function validate(PreApplyEvent $event): void {
$active_packages_list = $this->composerInspector->getInstalledPackagesList($this->pathLocator->getProjectRoot());
$stage_packages_list = $this->composerInspector->getInstalledPackagesList($event->stage->getStageDirectory());
$extensions_list = $this->moduleHandler->getModuleList() + $this->themeHandler->listInfo();
foreach ($extensions_list as $extension) {
$extension_name = $extension->getName();
$package = $active_packages_list->getPackageByDrupalProjectName($extension_name);
if ($package && $stage_packages_list->getPackageByDrupalProjectName($extension_name) === NULL) {
$removed_project_messages[] = t("'@name' @type (provided by <code>@package</code>)", [
'@name' => $extension_name,
'@type' => $extension->getType(),
'@package' => $package->name,
]);
}
}
if (!empty($removed_project_messages)) {
$removed_packages_summary = $this->formatPlural(
count($removed_project_messages),
'The update cannot proceed because the following enabled Drupal extension was removed during the update.',
'The update cannot proceed because the following enabled Drupal extensions were removed during the update.'
);
$event->addError($removed_project_messages, $removed_packages_summary);
}
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
return [
PreApplyEvent::class => 'validate',
];
}
}
<?php
declare(strict_types = 1);
namespace Drupal\package_manager\Validator;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\package_manager\Event\PreOperationStageEvent;
use Drupal\Core\Url;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Checks that the environment has support for Package Manager.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
final class EnvironmentSupportValidator implements EventSubscriberInterface {
use BaseRequirementValidatorTrait {
getSubscribedEvents as private getSubscribedEventsFromTrait;
}
use StringTranslationTrait;
/**
* The name of the environment variable to check.
*
* This environment variable, if defined, should be parseable by
* \Drupal\Core\Url::fromUri() and link to an explanation of why Package
* Manager is not supported in the current environment.
*
* @var string
*/
public const VARIABLE_NAME = 'DRUPAL_PACKAGE_MANAGER_NOT_SUPPORTED_HELP_URL';
/**
* Checks that this environment supports Package Manager.
*/
public function validate(PreOperationStageEvent $event): void {
$message = $this->t('Package Manager is not supported by your environment.');
$help_url = getenv(static::VARIABLE_NAME);
if (empty($help_url)) {
return;
}
// If the URL is not parseable, catch the exception that Url::fromUri()
// would generate.
try {
$message = $this->t('<a href=":url">@message</a>', [
':url' => Url::fromUri($help_url)->toString(),
'@message' => $message,
]);
}
catch (\InvalidArgumentException) {
// No need to do anything here. The message just won't be a link.
}
$event->addError([$message]);
// If Package Manager is unsupported, there's no point in doing any more
// validation.
$event->stopPropagation();
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
// Set priority to run before BaseRequirementsFulfilledValidator, and even
// before other base requirement validators.
// @see \Drupal\package_manager\Validator\BaseRequirementsFulfilledValidator
return array_map(fn () => ['validate', BaseRequirementsFulfilledValidator::PRIORITY + 1000], static::getSubscribedEventsFromTrait());
}
}
<?php
declare(strict_types = 1);
namespace Drupal\package_manager\Validator;
use Drupal\Core\State\StateInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\StringTranslation\TranslationInterface;
use Drupal\package_manager\Event\PostDestroyEvent;
use Drupal\package_manager\Event\PostApplyEvent;
use Drupal\package_manager\Event\PreApplyEvent;
use Drupal\package_manager\Event\PreCreateEvent;
use Drupal\package_manager\Event\PreOperationStageEvent;
use Drupal\package_manager\Event\PreRequireEvent;
use Drupal\package_manager\Event\StatusCheckEvent;
use Drupal\package_manager\PathLocator;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
/**
* Checks that the active lock file is unchanged during stage operations.
*
* @internal
* This is an internal part of Package Manager and may be changed or removed
* at any time without warning. External code should not interact with this
* class.
*/
class LockFileValidator implements PreOperationStageValidatorInterface {
final class LockFileValidator implements EventSubscriberInterface {
use StringTranslationTrait;
......@@ -24,111 +32,131 @@ class LockFileValidator implements PreOperationStageValidatorInterface {
*
* @var string
*/
protected const STATE_KEY = 'package_manager.lock_hash';
/**
* The state service.
*
* @var \Drupal\Core\State\StateInterface
*/
protected $state;
/**
* The path locator service.
*
* @var \Drupal\package_manager\PathLocator
*/
protected $pathLocator;
private const STATE_KEY = 'package_manager.lock_hash';
/**
* Constructs a LockFileValidator object.
*
* @param \Drupal\Core\State\StateInterface $state
* The state service.
* @param \Drupal\package_manager\PathLocator $path_locator
* @param \Drupal\package_manager\PathLocator $pathLocator
* The path locator service.
* @param \Drupal\Core\StringTranslation\TranslationInterface $translation
* The string translation service.
*/
public function __construct(StateInterface $state, PathLocator $path_locator, TranslationInterface $translation) {
$this->state = $state;
$this->pathLocator = $path_locator;
$this->setStringTranslation($translation);
}
public function __construct(
private readonly StateInterface $state,
private readonly PathLocator $pathLocator
) {}
/**
* Returns the current hash of the given directory's lock file.
* Returns the SHA-256 hash of a file.
*
* @param string $directory
* Path of a directory containing a composer.lock file.
* This method is a thin wrapper around hash_file() to facilitate testing. On
* failure, hash_file() emits a warning but doesn't throw an exception. In
* tests, however, PHPUnit converts warnings to exceptions, so we need to
* catch those and convert them to the value hash_file() will actually return
* on error, which is FALSE. We could also just call `hash_file` directly and
* use @ to suppress warnings, but those would be unclear and likely to be
* accidentally removed later.
*
* @param string $path
* Path of the file to hash.
*
* @return string|false
* The hash of the given directory's lock file, or FALSE if the lock file
* does not exist.
* The hash of the given file, or FALSE if the file doesn't exist or cannot
* be hashed.
*/
protected function getLockFileHash(string $directory) {
$file = $directory . DIRECTORY_SEPARATOR . 'composer.lock';
// We want to directly hash the lock file itself, rather than look at its
// content-hash value, which is actually a hash of the relevant parts of
// composer.json. We're trying to verify that the actual installed packages
// have not changed; we don't care about the constraints in composer.json.
private function getHash(string $path) {
try {
return hash_file('sha256', $file);
return hash_file('sha256', $path);
}
catch (\Throwable $exception) {
catch (\Throwable) {
return FALSE;
}
}
/**
* Stores the current lock file hash.
* Stores the SHA-256 hash of the active lock file.
*
* We store the hash of the lock file itself, rather than its content-hash
* value, which is actually a hash of certain parts of composer.json. Our aim
* is to verify that the actual installed packages have not changed
* unexpectedly; we don't care about the contents of composer.json.
*
* @param \Drupal\package_manager\Event\PreCreateEvent $event
* The event being handled.
*/
public function storeHash(PreCreateEvent $event): void {
$hash = $this->getLockFileHash($this->pathLocator->getProjectRoot());
$active_lock_file_path = $this->pathLocator->getProjectRoot() . DIRECTORY_SEPARATOR . 'composer.lock';
$hash = $this->getHash($active_lock_file_path);
if ($hash) {
$this->state->set(static::STATE_KEY, $hash);
}
else {
$event->addError([
$this->t('Could not hash the active lock file.'),
$this->t('The active lock file (@file) does not exist.', [
'@file' => $active_lock_file_path,
]),
]);
}
}
/**
* {@inheritdoc}
* Checks that the active lock file is unchanged during stage operations.
*
* @param \Drupal\package_manager\Event\PreOperationStageEvent $event
* The event being handled.
*/
public function validateStagePreOperation(PreOperationStageEvent $event): void {
public function validate(PreOperationStageEvent $event): void {
$stage = $event->stage;
// Early return if the stage is not already created.
if ($event instanceof StatusCheckEvent && $stage->isAvailable()) {
return;
}
$messages = [];
// Ensure we can get a current hash of the lock file.
$active_hash = $this->getLockFileHash($this->pathLocator->getProjectRoot());
if (empty($active_hash)) {
$error = $this->t('Could not hash the active lock file.');
$active_lock_file_path = $this->pathLocator->getProjectRoot() . DIRECTORY_SEPARATOR . 'composer.lock';
$active_lock_file_hash = $this->getHash($active_lock_file_path);
if (empty($active_lock_file_hash)) {
$messages[] = $this->t('The active lock file (@file) does not exist.', [
'@file' => $active_lock_file_path,
]);
}
// Ensure we also have a stored hash of the lock file.
$stored_hash = $this->state->get(static::STATE_KEY);
if (empty($stored_hash)) {
$error = $this->t('Could not retrieve stored hash of the active lock file.');
$active_lock_file_stored_hash = $this->state->get(static::STATE_KEY);
if (empty($active_lock_file_stored_hash)) {
throw new \LogicException('Stored hash key deleted.');
}
// If we have both hashes, ensure they match.
if ($active_hash && $stored_hash && !hash_equals($stored_hash, $active_hash)) {
$error = $this->t('Stored lock file hash does not match the active lock file.');
if ($active_lock_file_hash && $active_lock_file_stored_hash && !hash_equals($active_lock_file_stored_hash, $active_lock_file_hash)) {
$messages[] = $this->t('Unexpected changes were detected in the active lock file (@file), which indicates that other Composer operations were performed since this Package Manager operation started. This can put the code base into an unreliable state and therefore is not allowed.', [
'@file' => $active_lock_file_path,
]);
}
// Don't allow staged changes to be applied if the staged lock file has no
// apparent changes.
if (empty($error) && $event instanceof PreApplyEvent) {
$stage_hash = $this->getLockFileHash($event->getStage()->getStageDirectory());
if ($stage_hash && hash_equals($active_hash, $stage_hash)) {
$error = $this->t('There are no pending Composer operations.');
if (empty($messages) && $event instanceof PreApplyEvent) {
$staged_lock_file_path = $stage->getStageDirectory() . DIRECTORY_SEPARATOR . 'composer.lock';
$staged_lock_file_hash = $this->getHash($staged_lock_file_path);
if ($staged_lock_file_hash && hash_equals($active_lock_file_hash, $staged_lock_file_hash)) {
$messages[] = $this->t('There appear to be no pending Composer operations because the active lock file (@active_file) and the staged lock file (@staged_file) are identical.', [
'@active_file' => $active_lock_file_path,
'@staged_file' => $staged_lock_file_path,
]);
}
}
// @todo Let the validation result carry all the relevant messages in
// https://www.drupal.org/i/3247479.
if (isset($error)) {
$event->addError([$error]);
if (!empty($messages)) {
$summary = $this->formatPlural(
count($messages),
'Problem detected in lock file during stage operations.',
'Problems detected in lock file during stage operations.',
);
$event->addError($messages, $summary);
}
}
......@@ -142,12 +170,13 @@ class LockFileValidator implements PreOperationStageValidatorInterface {
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents() {
public static function getSubscribedEvents(): array {
return [
PreCreateEvent::class => 'storeHash',
PreRequireEvent::class => 'validateStagePreOperation',
PreApplyEvent::class => 'validateStagePreOperation',
PostDestroyEvent::class => 'deleteHash',
PreRequireEvent::class => 'validate',
PreApplyEvent::class => 'validate',
StatusCheckEvent::class => 'validate',
PostApplyEvent::class => 'deleteHash',
];
}
......