@@ -121,30 +121,38 @@ bool storage_s3_validate(const storage_target_t *target, char *error, size_t len
121121 return message == NULL ;
122122}
123123
124- static int credentials_read (const storage_target_t * target , s3_credentials_t * credentials ) {
124+ /* Credentials and operator attestations share the same protected file boundary. */
125+ static cJSON * private_json_read (const storage_target_t * target , const char * suffix ) {
125126 const char * directory = getenv ("LIGHTNVR_ARCHIVE_CREDENTIALS_DIR" );
126127 if (!directory ) directory = "/etc/lightnvr/archive-credentials" ;
127- if (!safe_identifier (target -> credential_ref , sizeof (target -> credential_ref ), false)) return -1 ;
128+ if (!safe_identifier (target -> credential_ref , sizeof (target -> credential_ref ), false)) return NULL ;
129+ char filename [sizeof (target -> credential_ref ) + 32 ];
130+ snprintf (filename , sizeof (filename ), "%s%s" , target -> credential_ref , suffix );
128131 int dir = open (directory , O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW );
129- if (dir < 0 ) return -1 ;
130- int descriptor = openat (dir , target -> credential_ref , O_RDONLY | O_CLOEXEC | O_NOFOLLOW );
132+ if (dir < 0 ) return NULL ;
133+ int descriptor = openat (dir , filename , O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK );
131134 close (dir );
132- if (descriptor < 0 ) return -1 ;
135+ if (descriptor < 0 ) return NULL ;
133136 struct stat status ;
134137 char buffer [S3_SECRET_LIMIT + 1 ];
135138 bool valid = fstat (descriptor , & status ) == 0 && S_ISREG (status .st_mode ) &&
136139 !(status .st_mode & 0077 ) && (status .st_uid == 0 || status .st_uid == geteuid ()) &&
137140 status .st_size > 0 && status .st_size <= S3_SECRET_LIMIT ;
138141 ssize_t count = valid ? read (descriptor , buffer , sizeof (buffer ) - 1 ) : -1 ;
139142 close (descriptor );
140- if (count <= 0 || count != status .st_size ) return -1 ;
143+ if (count <= 0 || count != status .st_size ) return NULL ;
141144 buffer [count ] = 0 ;
142145 cJSON * json = cJSON_Parse (buffer );
143146 wipe (buffer , sizeof (buffer ));
147+ return json ;
148+ }
149+
150+ static int credentials_read (const storage_target_t * target , s3_credentials_t * credentials ) {
151+ cJSON * json = private_json_read (target , "" );
144152 const cJSON * access = cJSON_GetObjectItemCaseSensitive (json , "access_key_id" );
145153 const cJSON * secret = cJSON_GetObjectItemCaseSensitive (json , "secret_access_key" );
146154 const cJSON * token = cJSON_GetObjectItemCaseSensitive (json , "session_token" );
147- valid = cJSON_IsString (access ) && cJSON_IsString (secret ) &&
155+ bool valid = cJSON_IsString (access ) && cJSON_IsString (secret ) &&
148156 access -> valuestring [0 ] && secret -> valuestring [0 ] &&
149157 strlen (access -> valuestring ) < sizeof (credentials -> access ) &&
150158 strlen (secret -> valuestring ) < sizeof (credentials -> secret ) &&
@@ -235,6 +243,19 @@ static int request(const storage_target_t *target, const char *key, const char *
235243 snprintf (raw_key , sizeof (raw_key ), "%s%s%s" , key ? prefix : "" ,
236244 key && * prefix && prefix [strlen (prefix ) - 1 ] != '/' ? "/" : "" , key ? key : "" );
237245 char * encoded = curl ? curl_easy_escape (curl , raw_key , 0 ) : NULL ;
246+ /* S3 SigV4 preserves object-key path separators. Escaping a slash as %2F
247+ * signs a different canonical URI on providers such as Spaces. Literal
248+ * percent signs stay escaped, so a key containing "%2F" is not a slash. */
249+ if (encoded ) {
250+ char * read = encoded , * write = encoded ;
251+ while (* read ) {
252+ if (!strncmp (read , "%2F" , 3 )) {
253+ * write ++ = '/' ;
254+ read += 3 ;
255+ } else * write ++ = * read ++ ;
256+ }
257+ * write = 0 ;
258+ }
238259 char url [4 * MAX_PATH_LENGTH + 512 ];
239260 char endpoint [MAX_PATH_LENGTH ];
240261 safe_strcpy (endpoint , target -> endpoint , sizeof (endpoint ), 0 );
@@ -681,6 +702,28 @@ static bool safe_lifecycle(char *body) {
681702 return safe ;
682703}
683704
705+ /* Some providers deny lifecycle GET to bucket-scoped object credentials.
706+ * A trusted operator may supply a fresh, bucket-bound inspection beside the
707+ * credentials. Never infer safety from a 403 or bypass a readable unsafe rule.
708+ * Refresh at least every 30 minutes; stale/malformed files fail closed. */
709+ static bool lifecycle_attested (const storage_target_t * target ) {
710+ cJSON * json = private_json_read (target , ".lifecycle.json" );
711+ const cJSON * endpoint = cJSON_GetObjectItemCaseSensitive (json , "endpoint" );
712+ const cJSON * region = cJSON_GetObjectItemCaseSensitive (json , "region" );
713+ const cJSON * bucket = cJSON_GetObjectItemCaseSensitive (json , "bucket" );
714+ const cJSON * checked = cJSON_GetObjectItemCaseSensitive (json , "checked_at" );
715+ const cJSON * xml = cJSON_GetObjectItemCaseSensitive (json , "lifecycle_configuration_xml" );
716+ double now = (double )time (NULL );
717+ bool valid = cJSON_IsString (endpoint ) && !strcmp (endpoint -> valuestring , target -> endpoint ) &&
718+ cJSON_IsString (region ) && !strcmp (region -> valuestring , target -> region ) &&
719+ cJSON_IsString (bucket ) && !strcmp (bucket -> valuestring , target -> bucket ) &&
720+ cJSON_IsNumber (checked ) && checked -> valuedouble > 0 &&
721+ checked -> valuedouble <= now && now - checked -> valuedouble < 3600 &&
722+ cJSON_IsString (xml ) && safe_lifecycle (xml -> valuestring );
723+ cJSON_Delete (json );
724+ return valid ;
725+ }
726+
684727int storage_s3_probe (storage_target_t * target , bool write_test ) {
685728 char error [STORAGE_TARGET_ERROR_MAX ] = {0 };
686729 char * body = calloc (1 , S3_REPLY_LIMIT );
@@ -696,7 +739,10 @@ int storage_s3_probe(storage_target_t *target, bool write_test) {
696739 io .length = 0 ; io .bytes = 0 ;
697740 result = request (target , NULL , "lifecycle" , "GET" , NULL , 0 , NULL , & io , NULL , error );
698741 if (result == STORAGE_S3_MISSING ) result = 0 ;
699- else if (result == 0 && !safe_lifecycle (body )) {
742+ else if (result != 0 && io .status == 403 ) {
743+ if (lifecycle_attested (target )) result = 0 ;
744+ else error_set (error , "Lifecycle inspection denied; a fresh operator-verified .lifecycle.json file is required with bucket-scoped credentials" );
745+ } else if (result == 0 && !safe_lifecycle (body )) {
700746 error_set (error , "Bucket lifecycle may only abort incomplete uploads after 1-7 days; expiry and transitions are unsupported" );
701747 result = STORAGE_S3_CONFLICT ;
702748 }
0 commit comments