@@ -89,7 +89,10 @@ public void PostgresImagePin_IsOneIdenticalStringAcrossEveryTrackedFile()
8989 // no colon for the pattern above to see, so it needs its own
9090 // detector, scoped to the two syntaxes where a bare name is a live
9191 // image reference rather than prose.
92- var untaggedPattern = new Regex ( @"(?im)^\s*(?:image:\s*|FROM\s+)[""']?postgres[""']?(?=\s|$)" ) ;
92+ // Namespaced/registry-qualified forms (docker.io/library/postgres,
93+ // registry:5000/ns/postgres) float to latest exactly the same way,
94+ // so the optional prefix segments are part of the detector.
95+ var untaggedPattern = new Regex ( @"(?im)^\s*(?:image:\s*|FROM\s+)[""']?(?:[a-z0-9.-]+(?::\d+)?/)*postgres[""']?(?=\s|$)" ) ;
9396 var hits = new Dictionary < string , List < string > > ( ) ;
9497
9598 foreach ( var relative in TrackedFiles ( ) )
@@ -145,6 +148,10 @@ public void CommittedSchemaDocs_CarryNoEnvironmentSpecificContent()
145148 ( "absolute windows path" , new Regex ( @"(?i)[a-z]:\\" ) ) ,
146149 ( "timestamp" , new Regex ( @"\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}" ) ) ,
147150 ( "connection URI (would carry the ephemeral password)" , new Regex ( @"postgres(?:ql)?://" ) ) ,
151+ // Same portability class MigrationSecurityReviewTests fences on
152+ // the migration digest: an assembly file name is stable across
153+ // machines, so the byte-diff can't catch it either.
154+ ( "assembly artifact" , new Regex ( @"[\w.-]+\.(?:dll|pdb)\b" , RegexOptions . IgnoreCase ) ) ,
148155 } ;
149156
150157 var failures = new StringBuilder ( ) ;
@@ -217,11 +224,13 @@ void RequireRow(string table, string name, string def, string kind)
217224 // so a page-wide cell search would accept an omitted column that
218225 // happens to be named like a header (Customers.Name). Only the data
219226 // rows of the Columns table count — and the row's METADATA cells are
220- // held to the catalog too: default and nullability are exact (tbls
221- // prints pg_get_expr / true|false verbatim), the type cell must at
222- // least be non-empty (tbls normalizes type names — e.g. varchar(16)
223- // for character varying — and mirroring that normalizer here would be
224- // a parity chase; the byte-diff pins the exact text instead).
227+ // held to the catalog exactly: type, default, and nullability. The
228+ // type comparison uses format_type with tbls's single observed
229+ // normalization (character varying → varchar; every other type cell
230+ // across all 38 tables is format_type verbatim) — a future type this
231+ // mapping doesn't cover fails loudly and gets added consciously,
232+ // which beats both silently trusting the cell and mirroring tbls's
233+ // whole normalizer.
225234 foreach ( var col in await QueryColumnsAsync ( conn ) )
226235 {
227236 var lines = LinesOf ( col . Table ) ;
@@ -243,8 +252,9 @@ void RequireRow(string table, string name, string def, string kind)
243252 missing . AppendLine ( $ "column absent from the Columns section of public.{ col . Table } .md: { col . Column } ") ;
244253 continue ;
245254 }
246- if ( row [ 2 ] . Length == 0 )
247- missing . AppendLine ( $ "column type cell empty in public.{ col . Table } .md: { col . Column } ") ;
255+ var expectedType = col . Type . Replace ( "character varying" , "varchar" ) ;
256+ if ( row [ 2 ] != expectedType )
257+ missing . AppendLine ( $ "column type mismatch in public.{ col . Table } .md: { col . Column } — docs \" { row [ 2 ] } \" , catalog \" { expectedType } \" ") ;
248258 if ( row [ 3 ] != col . Default )
249259 missing . AppendLine ( $ "column default mismatch in public.{ col . Table } .md: { col . Column } — docs \" { row [ 3 ] } \" , catalog \" { col . Default } \" ") ;
250260 if ( row [ 4 ] != col . Nullable )
@@ -307,24 +317,30 @@ private static async Task<List<string>> QueryStringsAsync(
307317 return results ;
308318 }
309319
310- private static async Task < List < ( string Table , string Column , string Default , string Nullable ) > >
320+ private static async Task < List < ( string Table , string Column , string Type , string Default , string Nullable ) > >
311321 QueryColumnsAsync ( System . Data . Common . DbConnection conn )
312322 {
313- var results = new List < ( string , string , string , string ) > ( ) ;
323+ var results = new List < ( string , string , string , string , string ) > ( ) ;
314324 await using var cmd = conn . CreateCommand ( ) ;
315- // column_default and is_nullable are rendered by tbls verbatim
316- // (pg_get_expr text; "true"/"false") — comparable exactly .
325+ // Defaults and nullability are rendered by tbls verbatim
326+ // (pg_get_expr text; "true"/"false"); types via format_type .
317327 cmd . CommandText =
318328 """
319- SELECT table_name, column_name, COALESCE(column_default, ''),
320- CASE WHEN is_nullable = 'YES' THEN 'true' ELSE 'false' END
321- FROM information_schema.columns
322- WHERE table_schema = 'public'
323- ORDER BY table_name, ordinal_position
329+ SELECT c.relname, a.attname, format_type(a.atttypid, a.atttypmod),
330+ COALESCE(pg_get_expr(d.adbin, d.adrelid), ''),
331+ CASE WHEN a.attnotnull THEN 'false' ELSE 'true' END
332+ FROM pg_attribute a
333+ JOIN pg_class c ON c.oid = a.attrelid
334+ JOIN pg_namespace n ON n.oid = c.relnamespace
335+ LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
336+ WHERE n.nspname = 'public' AND c.relkind = 'r'
337+ AND a.attnum > 0 AND NOT a.attisdropped
338+ ORDER BY c.relname, a.attnum
324339 """ ;
325340 await using var reader = await cmd . ExecuteReaderAsync ( ) ;
326341 while ( await reader . ReadAsync ( ) )
327- results . Add ( ( reader . GetString ( 0 ) , reader . GetString ( 1 ) , reader . GetString ( 2 ) , reader . GetString ( 3 ) ) ) ;
342+ results . Add ( ( reader . GetString ( 0 ) , reader . GetString ( 1 ) , reader . GetString ( 2 ) ,
343+ reader . GetString ( 3 ) , reader . GetString ( 4 ) ) ) ;
328344 return results ;
329345 }
330346
0 commit comments