@@ -104,22 +104,31 @@ pub fn save_umls_artifact(
104104
105105/// Load a UMLS artifact: returns (header, shard_bytes_list)
106106pub fn load_umls_artifact ( path : & Path ) -> anyhow:: Result < ( ArtifactHeader , Vec < Vec < u8 > > ) > {
107- // Advisory permission check: a world-writable artifact file allows an
108- // attacker to forge both bytes and stored checksums together, bypassing
109- // the integrity gate. Log a warning but do not abort — this is P2
110- // advisory (see Gitea #1587). Binding this to Unix keeps Windows builds clean.
107+ // Permission enforcement: any write bit beyond the owner is a hard
108+ // error. An attacker who can write the artifact can forge both bytes
109+ // and stored checksums together, bypassing the integrity gate. The
110+ // checksum guard is only effective when the file is owner-writable
111+ // exclusively. Binding this to Unix keeps Windows builds clean;
112+ // Windows callers should apply ACLs externally.
111113 #[ cfg( unix) ]
112114 {
113115 use std:: os:: unix:: fs:: PermissionsExt ;
114116 if let Ok ( meta) = std:: fs:: metadata ( path) {
115117 let mode = meta. permissions ( ) . mode ( ) ;
118+ let mut reasons = Vec :: new ( ) ;
116119 if mode & 0o002 != 0 {
117- log:: warn!(
118- "Artifact {:?} is world-writable (mode {:04o}). \
119- An attacker with filesystem write access could forge a \
120- valid artifact and bypass checksum verification.",
120+ reasons. push ( "world-writable" . to_string ( ) ) ;
121+ }
122+ if mode & 0o020 != 0 {
123+ reasons. push ( "group-writable" . to_string ( ) ) ;
124+ }
125+ if !reasons. is_empty ( ) {
126+ anyhow:: bail!(
127+ "Artifact {:?} has insecure permissions (mode {:04o}): {}. \
128+ Restrict to owner-only write (e.g. chmod 600) and re-save.",
121129 path,
122- mode
130+ mode,
131+ reasons. join( ", " )
123132 ) ;
124133 }
125134 }
@@ -290,15 +299,13 @@ mod tests {
290299 assert ! ( msg. contains( "checksum mismatch" ) , "error: {}" , msg) ;
291300 }
292301
293- /// Verify that a world-writable artifact still loads successfully .
302+ /// Verify that a world-writable artifact is rejected as a hard error .
294303 ///
295- /// The permission check is advisory (P2, Gitea #1587): it emits a log
296- /// warning but does not abort loading. The checksum gate still protects
297- /// against _tampered_ bytes; the warning alerts operators to tighten
298- /// filesystem permissions.
304+ /// An attacker who can write the file can forge both bytes and
305+ /// checksums. The permission gate rejects before any data is read.
299306 #[ test]
300307 #[ cfg( unix) ]
301- fn test_world_writable_artifact_loads_ok ( ) {
308+ fn test_world_writable_artifact_rejected ( ) {
302309 use std:: os:: unix:: fs:: PermissionsExt ;
303310 let dir = tempdir ( ) . unwrap ( ) ;
304311 let path = dir. path ( ) . join ( "world_writable.bin.zst" ) ;
@@ -312,11 +319,63 @@ mod tests {
312319 perms. set_mode ( 0o666 ) ;
313320 std:: fs:: set_permissions ( & path, perms) . unwrap ( ) ;
314321
315- // Loading must succeed — the warning is advisory, not fatal
322+ let result = load_umls_artifact ( & path) ;
323+ assert ! ( result. is_err( ) , "World-writable artifact must be rejected" ) ;
324+ let msg = result. err ( ) . unwrap ( ) . to_string ( ) ;
325+ assert ! (
326+ msg. contains( "world-writable" ) ,
327+ "error must mention world-writable, got: {msg}"
328+ ) ;
329+ }
330+
331+ /// Verify that a group-writable artifact is rejected.
332+ #[ test]
333+ #[ cfg( unix) ]
334+ fn test_group_writable_artifact_rejected ( ) {
335+ use std:: os:: unix:: fs:: PermissionsExt ;
336+ let dir = tempdir ( ) . unwrap ( ) ;
337+ let path = dir. path ( ) . join ( "group_writable.bin.zst" ) ;
338+
339+ let shard_bytes = vec ! [ vec![ 1u8 ; 10 ] , vec![ 2u8 ; 8 ] ] ;
340+ let header = make_test_header ( & shard_bytes) ;
341+ save_umls_artifact ( & header, & shard_bytes, & path) . unwrap ( ) ;
342+
343+ // Make group-writable (0o620)
344+ let mut perms = std:: fs:: metadata ( & path) . unwrap ( ) . permissions ( ) ;
345+ perms. set_mode ( 0o620 ) ;
346+ std:: fs:: set_permissions ( & path, perms) . unwrap ( ) ;
347+
348+ let result = load_umls_artifact ( & path) ;
349+ assert ! ( result. is_err( ) , "Group-writable artifact must be rejected" ) ;
350+ let msg = result. err ( ) . unwrap ( ) . to_string ( ) ;
351+ assert ! (
352+ msg. contains( "group-writable" ) ,
353+ "error must mention group-writable, got: {msg}"
354+ ) ;
355+ }
356+
357+ /// Verify that an artifact with secure owner-only permissions (0o600)
358+ /// loads successfully.
359+ #[ test]
360+ #[ cfg( unix) ]
361+ fn test_secure_permissions_artifact_loads_ok ( ) {
362+ use std:: os:: unix:: fs:: PermissionsExt ;
363+ let dir = tempdir ( ) . unwrap ( ) ;
364+ let path = dir. path ( ) . join ( "secure.bin.zst" ) ;
365+
366+ let shard_bytes = vec ! [ vec![ 1u8 ; 10 ] , vec![ 2u8 ; 8 ] ] ;
367+ let header = make_test_header ( & shard_bytes) ;
368+ save_umls_artifact ( & header, & shard_bytes, & path) . unwrap ( ) ;
369+
370+ // Secure: owner read+write only
371+ let mut perms = std:: fs:: metadata ( & path) . unwrap ( ) . permissions ( ) ;
372+ perms. set_mode ( 0o600 ) ;
373+ std:: fs:: set_permissions ( & path, perms) . unwrap ( ) ;
374+
316375 let result = load_umls_artifact ( & path) ;
317376 assert ! (
318377 result. is_ok( ) ,
319- "World -writable artifact must still load (advisory warning only) ; got: {:?}" ,
378+ "Owner -writable-only artifact must load; got: {:?}" ,
320379 result. err( )
321380 ) ;
322381 let ( loaded_header, loaded_shards) = result. unwrap ( ) ;
0 commit comments