diff options
author | Eric Wong <normalperson@yhbt.net> | 2009-08-28 20:47:43 -0700 |
---|---|---|
committer | Eric Wong <normalperson@yhbt.net> | 2009-08-28 20:48:10 -0700 |
commit | a70468036d9b780bc7ec921f7feb6e1275778169 (patch) | |
tree | 302c8b4f4a30203c9549dbd7579006a729c1830f | |
download | clogger-a70468036d9b780bc7ec921f7feb6e1275778169.tar.gz |
-rw-r--r-- | .gitignore | 13 | ||||
-rw-r--r-- | COPYING | 165 | ||||
-rw-r--r-- | GNUmakefile | 37 | ||||
-rw-r--r-- | History.txt | 5 | ||||
-rw-r--r-- | Manifest.txt | 19 | ||||
-rw-r--r-- | README.txt | 114 | ||||
-rw-r--r-- | Rakefile | 30 | ||||
-rw-r--r-- | ext/clogger_ext/clogger.c | 800 | ||||
-rw-r--r-- | ext/clogger_ext/extconf.rb | 12 | ||||
-rw-r--r-- | ext/clogger_ext/ruby_1_9_compat.h | 23 | ||||
-rw-r--r-- | lib/clogger.rb | 133 | ||||
-rw-r--r-- | lib/clogger/format.rb | 25 | ||||
-rw-r--r-- | lib/clogger/pure.rb | 126 | ||||
-rw-r--r-- | setup.rb | 1585 | ||||
-rw-r--r-- | test/test_clogger.rb | 349 |
15 files changed, 3436 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6d1222b --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +Makefile +*.log +*.rbc +*.so +*.o +*.a +*.bundle +.DS_Store +/.config +/InstalledFiles +/doc +/local.mk +/pkg @@ -0,0 +1,165 @@ + GNU LESSER GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/> + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + + This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below. + + 0. Additional Definitions. + + As used herein, "this License" refers to version 3 of the GNU Lesser +General Public License, and the "GNU GPL" refers to version 3 of the GNU +General Public License. + + "The Library" refers to a covered work governed by this License, +other than an Application or a Combined Work as defined below. + + An "Application" is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + + A "Combined Work" is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the "Linked +Version". + + The "Minimal Corresponding Source" for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + + The "Corresponding Application Code" for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + + 1. Exception to Section 3 of the GNU GPL. + + You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + + 2. Conveying Modified Versions. + + If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + + a) under this License, provided that you make a good faith effort to + ensure that, in the event an Application does not supply the + function or data, the facility still operates, and performs + whatever part of its purpose remains meaningful, or + + b) under the GNU GPL, with none of the additional permissions of + this License applicable to that copy. + + 3. Object Code Incorporating Material from Library Header Files. + + The object code form of an Application may incorporate material from +a header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + + a) Give prominent notice with each copy of the object code that the + Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the object code with a copy of the GNU GPL and this license + document. + + 4. Combined Works. + + You may convey a Combined Work under terms of your choice that, +taken together, effectively do not restrict modification of the +portions of the Library contained in the Combined Work and reverse +engineering for debugging such modifications, if you also do each of +the following: + + a) Give prominent notice with each copy of the Combined Work that + the Library is used in it and that the Library and its use are + covered by this License. + + b) Accompany the Combined Work with a copy of the GNU GPL and this license + document. + + c) For a Combined Work that displays copyright notices during + execution, include the copyright notice for the Library among + these notices, as well as a reference directing the user to the + copies of the GNU GPL and this license document. + + d) Do one of the following: + + 0) Convey the Minimal Corresponding Source under the terms of this + License, and the Corresponding Application Code in a form + suitable for, and under terms that permit, the user to + recombine or relink the Application with a modified version of + the Linked Version to produce a modified Combined Work, in the + manner specified by section 6 of the GNU GPL for conveying + Corresponding Source. + + 1) Use a suitable shared library mechanism for linking with the + Library. A suitable mechanism is one that (a) uses at run time + a copy of the Library already present on the user's computer + system, and (b) will operate properly with a modified version + of the Library that is interface-compatible with the Linked + Version. + + e) Provide Installation Information, but only if you would otherwise + be required to provide such information under section 6 of the + GNU GPL, and only to the extent that such information is + necessary to install and execute a modified version of the + Combined Work produced by recombining or relinking the + Application with a modified version of the Linked Version. (If + you use option 4d0, the Installation Information must accompany + the Minimal Corresponding Source and Corresponding Application + Code. If you use option 4d1, you must provide the Installation + Information in the manner specified by section 6 of the GNU GPL + for conveying Corresponding Source.) + + 5. Combined Libraries. + + You may place library facilities that are a work based on the +Library side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + + a) Accompany the combined library with a copy of the same work based + on the Library, uncombined with any other library facilities, + conveyed under the terms of this License. + + b) Give prominent notice with the combined library that part of it + is a work based on the Library, and explaining where to find the + accompanying uncombined form of the same work. + + 6. Revised Versions of the GNU Lesser General Public License. + + The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + + Each version is given a distinguishing version number. If the +Library as you received it specifies that a certain numbered version +of the GNU Lesser General Public License "or any later version" +applies to it, you have the option of following the terms and +conditions either of that published version or of any later version +published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser +General Public License, you may choose any version of the GNU Lesser +General Public License ever published by the Free Software Foundation. + + If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. diff --git a/GNUmakefile b/GNUmakefile new file mode 100644 index 0000000..3481564 --- /dev/null +++ b/GNUmakefile @@ -0,0 +1,37 @@ +all:: test +ruby = ruby + +-include local.mk + +ifeq ($(DLEXT),) # "so" for Linux + DLEXT := $(shell $(ruby) -rrbconfig -e 'puts Config::CONFIG["DLEXT"]') +endif + +ifeq ($(RUBY_VERSION),) + RUBY_VERSION := $(shell $(ruby) -e 'puts RUBY_VERSION') +endif + +ext/clogger_ext/Makefile: ext/clogger_ext/clogger.c ext/clogger_ext/extconf.rb + cd ext/clogger_ext && $(ruby) extconf.rb + +ext/clogger_ext/clogger.$(DLEXT): ext/clogger_ext/Makefile + $(MAKE) -C ext/clogger_ext + +clean: + -$(MAKE) -C ext/clogger_ext clean + $(RM) ext/clogger_ext/Makefile lib/clogger_ext.$(DLEXT) + +test-ext: ext/clogger_ext/clogger.$(DLEXT) + $(ruby) -Iext/clogger_ext:lib test/test_clogger.rb + +test-pure: + $(ruby) -Ilib test/test_clogger.rb + +test: test-ext test-pure + +Manifest.txt: + git ls-files > $@+ + cmp $@+ $@ || mv $@+ $@ + $(RM) -f $@+ + +.PHONY: test doc Manifest.txt diff --git a/History.txt b/History.txt new file mode 100644 index 0000000..51bd07d --- /dev/null +++ b/History.txt @@ -0,0 +1,5 @@ +=== 0.0.1 / 2009-08-28 + +* 1 major enhancement + + * initial release diff --git a/Manifest.txt b/Manifest.txt new file mode 100644 index 0000000..ec8915c --- /dev/null +++ b/Manifest.txt @@ -0,0 +1,19 @@ +.gitignore +COPYING +GNUmakefile +History.txt +Manifest.txt +README.txt +Rakefile +benchmarks/rack_common_logger_0_9_1.rb +benchmarks/rack_common_logger_1_0_0.rb +benchmarks/rack_common_logger_ew.rb +benchmarks/test_run.rb +ext/clogger_ext/clogger.c +ext/clogger_ext/extconf.rb +ext/clogger_ext/ruby_1_9_compat.h +lib/clogger.rb +lib/clogger/format.rb +lib/clogger/pure.rb +setup.rb +test/test_clogger.rb diff --git a/README.txt b/README.txt new file mode 100644 index 0000000..b57b97f --- /dev/null +++ b/README.txt @@ -0,0 +1,114 @@ += Clogger - configurable request logging for Rack + +* http://clogger.rubyforge.org/ +* mailto:clogger@librelist.com +* git://rubyforge.org/clogger.git +* http://clogger.rubyforge.org/git?p=clogger.git + +== DESCRIPTION + +Clogger is Rack middleware for logging HTTP requests. The log format +is customizable so you can specify exactly which fields to log. + +== FEATURES + +* pre-defines Apache Common Log Format, Apache Combined Log Format and + Rack::CommonLogger (as distributed by Rack 1.0) formats. + +* highly customizable with easy-to-read nginx-like log formatting variables. + +* Untrusted values are escaped (all HTTP headers, request URI components) + to make life easier for HTTP log parsers. The following bytes are escaped: + + ' (single quote) + " (double quote) + all bytes in the range of \x00-\x1f + +== SYNOPSIS + +Clogger may be loaded as Rack middleware in your config.ru: + + require "clogger" + use Clogger, + :format => Clogger::Format::Combined, + :logger => File.open("/path/to/log", "ab") + run YourApplication.new + +If you're using Rails 2.3.x or later, in your config/environment.rb +somewhere inside the "Rails::Initializer.run do |config|" block: + + config.middleware.use 'Clogger', + :format => Clogger::Format::Combined, + :logger => File.open("/path/to/log", "ab") + +== VARIABLES + +* $http_* - HTTP request headers (e.g. $http_user_agent) +* $sent_http_* - HTTP response headers (e.g. $sent_http_content_length) +* $cookie_* - HTTP request cookie (e.g. $cookie_session_id) + Rack::Request#cookies must have been used by the underlying application + to parse the cookies into a hash. +* $request_method - the HTTP request method (e.g. GET, POST, HEAD, ...) +* $path_info - path component requested (e.g. /index.html) +* $query_string - request query string (not including leading "?") +* $request_uri - the URI requested ($path_info?$query_string) +* $request - the first line of the HTTP request + ($request_method $request_uri $http_version) +* $request_time, $request_time{PRECISION} - time taken for request + (including response body iteration). PRECISION defaults to 3 + (milliseconds) if not specified but may be specified 0(seconds) to + 6(microseconds). +* $time_local, $time_local{FORMAT} - current local time, FORMAT defaults to + "%d/%b/%Y:%H:%M:%S %z" but accepts any strftime(3)-compatible format +* $time_utc, $time_utc{FORMAT} - like $time_local, except with UTC +* $usec - current time in seconds.microseconds since the Epoch +* $msec - current time in seconds.milliseconds since the Epoch +* $body_bytes_sent - bytes in the response body (Apache: %B) +* $response_length - body_bytes_sent, except "-" instead of "0" (Apache: %b) +* $remote_user - HTTP-authenticated user +* $remote_addr - IP of the requesting client socket +* $ip - X-Forwarded-For request header if available, $remote_addr if not +* $pid - process ID of the current process +* $e{Thread.current} - Thread processing the request +* $e{Actor.current} - Actor processing the request (Revactor or Rubinius) + +== REQUIREMENTS + +* Ruby, Rack + +== CONTACT + +All feedback (bug reports, user/development dicussion, patches, pull +requests) should go to the mailing list. Patches should be sent inline +(git format-patch -M + git send-email) so we can reply to them inline. + +* mailto:clogger@librelist.com + +== INSTALL: + +For Rubygems users: + + gem install clogger + +If you're using MRI 1.8 or 1.9 and have a build environment, you can also try: + + gem install clogger_ext + +A setup.rb file is also included if you do not use Rubygems. + +== LICENSE + +Copyright (C) 2009 Eric Wong <normalperson@yhbt.net> and contributors. + +Clogger is free software; you can redistribute it and/or modify it under +the terms of the GNU Lesser General Public License as published by the +Free Software Foundation, version 3.0. + +Clogger is distributed in the hope that it will be useful, but WITHOUT ANY +WARRANTY; without even the implied warranty of MERCHANTABILITY or +FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public +License for more details. + +You should have received a copy of the GNU Lesser General Public License +along with Clogger; if not, write to the Free Software Foundation, Inc., +51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..d1dcf85 --- /dev/null +++ b/Rakefile @@ -0,0 +1,30 @@ +require 'hoe' +$LOAD_PATH << 'lib' +require 'clogger' +begin + require 'rake/extensiontask' + Rake::ExtensionTask.new('clogger_ext') +rescue LoadError + warn "rake-compiler not available, cross compiling disabled" +end + +common = lambda do |hoe| + title = hoe.paragraphs_of("README.txt", 0).first.sub(/^= /, '') + hoe.version = Clogger::VERSION + hoe.summary = title.split(/\s*-\s*/, 2).last + hoe.description = hoe.paragraphs_of("README.txt", 3) + hoe.rubyforge_name = 'clogger' + hoe.author = 'Eric Wong' + hoe.email = 'clogger@librelist.com' + hoe.spec_extras.merge!('rdoc_options' => [ "--title", title ]) + hoe.remote_rdoc_dir = '' +end + +if ENV['CLOGGER_EXT'] + Hoe.spec('clogger_ext') do + common.call(self) + self.spec_extras.merge!(:extensions => Dir.glob('ext/*/extconf.rb')) + end +else + Hoe.spec('clogger') { common.call(self) } +end diff --git a/ext/clogger_ext/clogger.c b/ext/clogger_ext/clogger.c new file mode 100644 index 0000000..34927d3 --- /dev/null +++ b/ext/clogger_ext/clogger.c @@ -0,0 +1,800 @@ +#define _BSD_SOURCE +#include <ruby.h> +#include <assert.h> +#include <unistd.h> +#include <sys/types.h> +#include <sys/time.h> +#include <time.h> +#include <errno.h> +#ifdef HAVE_FCNTL_H +# include <fcntl.h> +#endif +#include "ruby_1_9_compat.h" + +/* in case _BSD_SOURCE doesn't give us this macro */ +#ifndef timersub +# define timersub(a, b, result) \ +do { \ + (result)->tv_sec = (a)->tv_sec - (b)->tv_sec; \ + (result)->tv_usec = (a)->tv_usec - (b)->tv_usec; \ + if ((result)->tv_usec < 0) { \ + --(result)->tv_sec; \ + (result)->tv_usec += 1000000; \ + } \ +} while (0) +#endif + +/* give GCC hints for better branch prediction + * (we layout branches so that ASCII characters are handled faster) */ +#if defined(__GNUC__) && (__GNUC__ >= 3) +# define likely(x) __builtin_expect (!!(x), 1) +# define unlikely(x) __builtin_expect (!!(x), 0) +#else +# define unlikely(x) (x) +# define likely(x) (x) +#endif + +enum clogger_opcode { + CL_OP_LITERAL = 0, + CL_OP_REQUEST, + CL_OP_RESPONSE, + CL_OP_SPECIAL, + CL_OP_EVAL, + CL_OP_TIME_LOCAL, + CL_OP_TIME_UTC, + CL_OP_REQUEST_TIME, + CL_OP_TIME, + CL_OP_COOKIE +}; + +enum clogger_special { + CL_SP_body_bytes_sent = 0, + CL_SP_status, + CL_SP_request, + CL_SP_request_length, + CL_SP_response_length, + CL_SP_ip, + CL_SP_pid +}; + +struct clogger { + VALUE app; + + VALUE fmt_ops; + VALUE logger; + VALUE log_buf; + + VALUE env; + VALUE cookies; + VALUE response; + + off_t body_bytes_sent; + struct timeval tv_start; + + int fd; + int wrap_body; + int need_resp; + int reentrant; /* tri-state, -1:auto, 1/0 true/false */ +}; + +static ID ltlt_id; +static ID call_id; +static ID each_id; +static ID close_id; +static ID to_i_id; +static ID to_s_id; +static ID size_id; +static VALUE cClogger; +static VALUE mFormat; + +/* common hash lookup keys */ +static VALUE g_HTTP_X_FORWARDED_FOR; +static VALUE g_REMOTE_ADDR; +static VALUE g_REQUEST_METHOD; +static VALUE g_PATH_INFO; +static VALUE g_QUERY_STRING; +static VALUE g_HTTP_VERSION; +static VALUE g_rack_errors; +static VALUE g_rack_input; +static VALUE g_rack_multithread; +static VALUE g_dash; +static VALUE g_empty; +static VALUE g_space; +static VALUE g_question_mark; +static VALUE g_rack_request_cookie_hash; + +#define LOG_BUF_INIT_SIZE 128 + +static void init_buffers(struct clogger *c) +{ + c->log_buf = rb_str_buf_new(LOG_BUF_INIT_SIZE); +} + +static inline int need_escape(unsigned c) +{ + assert(c <= 0xff); + return !!(c == '\'' || c == '"' || (c >= 0 && c <= 0x1f)); +} + +/* we are encoding-agnostic, clients can send us all sorts of junk */ +static VALUE byte_xs(VALUE from) +{ + static const char esc[] = "0123456789ABCDEF"; + unsigned char *new_ptr; + unsigned char *ptr = (unsigned char *)RSTRING_PTR(from); + long len = RSTRING_LEN(from); + long new_len = len; + VALUE rv; + + for (; --len >= 0; ptr++) { + unsigned c = *ptr; + + if (unlikely(need_escape(c))) + new_len += 3; /* { '\', 'x', 'X', 'X' } */ + } + + len = RSTRING_LEN(from); + if (new_len == len) + return from; + + rv = rb_str_new(0, new_len); + new_ptr = (unsigned char *)RSTRING_PTR(rv); + ptr = (unsigned char *)RSTRING_PTR(from); + for (; --len >= 0; ptr++) { + unsigned c = *ptr; + + if (unlikely(need_escape(c))) { + *new_ptr++ = '\\'; + *new_ptr++ = 'x'; + *new_ptr++ = esc[c >> 4]; + *new_ptr++ = esc[c & 0xf]; + } else { + *new_ptr++ = c; + } + } + assert(RSTRING_PTR(rv)[RSTRING_LEN(rv)] == '\0'); + + return rv; +} + +/* strcasecmp isn't locale independent, so we roll our own */ +static int str_case_eq(VALUE a, VALUE b) +{ + long alen = RSTRING_LEN(a); + long blen = RSTRING_LEN(b); + + if (alen == blen) { + const char *aptr = RSTRING_PTR(a); + const char *bptr = RSTRING_PTR(b); + + for (; alen--; ++aptr, ++bptr) { + if ((*bptr == *aptr) + || (*aptr >= 'A' && *aptr <= 'Z' && + (*aptr | 0x20) == *bptr)) + continue; + return 0; + } + return 1; + } + return 0; +} + +struct response_ops { long nr; VALUE ops; }; + +/* this can be worse than O(M*N) :<... but C loops are fast ... */ +static VALUE swap_sent_headers(VALUE kv, VALUE memo) +{ + struct response_ops *tmp = (struct response_ops *)memo; + VALUE key = RARRAY_PTR(kv)[0]; + long i = RARRAY_LEN(tmp->ops); + VALUE *ary = RARRAY_PTR(tmp->ops); + VALUE value; + + for (; --i >= 0; ary++) { + VALUE *op = RARRAY_PTR(*ary); + enum clogger_opcode opcode = NUM2INT(op[0]); + + if (opcode != CL_OP_RESPONSE) + continue; + assert(RARRAY_LEN(*ary) == 2); + if (!str_case_eq(key, op[1])) + continue; + + value = RARRAY_PTR(kv)[1]; + op[0] = INT2NUM(CL_OP_LITERAL); + op[1] = byte_xs(rb_obj_as_string(value)); + + if (!--tmp->nr) + rb_iter_break(); + return Qnil; + } + return Qnil; +} + +static VALUE sent_headers_ops(struct clogger *c) +{ + struct response_ops tmp; + long i, len; + VALUE *ary; + + if (!c->need_resp) + return c->fmt_ops; + + tmp.nr = 0; + tmp.ops = rb_ary_dup(c->fmt_ops); + len = RARRAY_LEN(tmp.ops); + ary = RARRAY_PTR(tmp.ops); + + for (i = 0; i < len; ++i) { + VALUE *op = RARRAY_PTR(ary[i]); + + if (NUM2INT(op[0]) == CL_OP_RESPONSE) { + assert(RARRAY_LEN(ary[i]) == 2); + ary[i] = rb_ary_dup(ary[i]); + ++tmp.nr; + } + } + + rb_iterate(rb_each, RARRAY_PTR(c->response)[1], + swap_sent_headers, (VALUE)&tmp); + + return tmp.ops; +} + +static void clogger_mark(void *ptr) +{ + struct clogger *c = ptr; + + rb_gc_mark_locations(&c->app, &c->response); +} + +static VALUE clogger_alloc(VALUE klass) +{ + struct clogger *c; + + return Data_Make_Struct(klass, struct clogger, clogger_mark, 0, c); +} + +static struct clogger *clogger_get(VALUE self) +{ + struct clogger *c; + + Data_Get_Struct(self, struct clogger, c); + assert(c); + return c; +} + +static VALUE obj_fileno(VALUE obj) +{ + return rb_funcall(obj, rb_intern("fileno"), 0); +} + +/* only for writing to regular files, not stupid crap like NFS */ +static void write_full(int fd, const void *buf, size_t count) +{ + ssize_t r; + + while (count > 0) { + r = write(fd, buf, count); + + if (r == count) { /* overwhelmingly likely */ + return; + } else if (r > 0) { + count -= r; + buf += r; + } else { + if (errno == EINTR || errno == EAGAIN) + continue; /* poor souls on NFS and like: */ + if (!errno) + errno = ENOSPC; + rb_sys_fail("write"); + } + } +} + +/* + * allow us to use write_full() iff we detect a blocking file + * descriptor that wouldn't play nicely with Ruby threading/fibers + */ +static int raw_fd(VALUE fileno) +{ +#if defined(HAVE_FCNTL) && defined(F_GETFL) && defined(O_NONBLOCK) + int fd; + int flags; + + if (NIL_P(fileno)) + return -1; + fd = NUM2INT(fileno); + + flags = fcntl(fd, F_GETFL); + if (flags < 0) + rb_sys_fail("fcntl"); + + return (flags & O_NONBLOCK) ? -1 : fd; +#else /* platforms w/o fcntl/F_GETFL/O_NONBLOCK */ + return -1; +#endif /* platforms w/o fcntl/F_GETFL/O_NONBLOCK */ +} + +/* :nodoc: */ +static VALUE clogger_reentrant(VALUE self) +{ + return clogger_get(self)->reentrant == 0 ? Qfalse : Qtrue; +} + +/* :nodoc: */ +static VALUE clogger_wrap_body(VALUE self) +{ + return clogger_get(self)->wrap_body == 0 ? Qfalse : Qtrue; +} + +static void append_status(struct clogger *c, VALUE status) +{ + char buf[sizeof("999")]; + int nr; + + if (TYPE(status) != T_FIXNUM) { + status = rb_funcall(status, to_i_id, 0); + /* no way it's a valid status code (at least not HTTP/1.1) */ + if (TYPE(status) != T_FIXNUM) { + rb_str_buf_append(c->log_buf, g_dash); + return; + } + } + + nr = NUM2INT(status); + if (nr >= 100 && nr <= 999) { + nr = snprintf(buf, sizeof(buf), "%03d", nr); + assert(nr == 3); + rb_str_buf_cat(c->log_buf, buf, nr); + } else { + /* raise?, swap for 500? */ + rb_str_buf_append(c->log_buf, g_dash); + } +} + +/* this is Rack 1.0.0-compatible, won't try to parse commas in XFF */ +static void append_ip(struct clogger *c) +{ + VALUE env = c->env; + VALUE tmp = rb_hash_aref(env, g_HTTP_X_FORWARDED_FOR); + + if (NIL_P(tmp)) { + /* can't be faked on any real server, so no escape */ + tmp = rb_hash_aref(env, g_REMOTE_ADDR); + if (NIL_P(tmp)) + tmp = g_dash; + } else { + tmp = byte_xs(tmp); + } + rb_str_buf_append(c->log_buf, tmp); +} + +static void append_body_bytes_sent(struct clogger *c) +{ + char buf[(sizeof(off_t) * 8) / 3 + 1]; + const char *fmt = sizeof(off_t) == sizeof(long) ? "%ld" : "%lld"; + int nr = snprintf(buf, sizeof(buf), fmt, c->body_bytes_sent); + + assert(nr > 0 && nr < sizeof(buf)); + rb_str_buf_cat(c->log_buf, buf, nr); +} + +static void append_tv(struct clogger *c, const VALUE *op, struct timeval *tv) +{ + char buf[sizeof(".000000") + ((sizeof(tv->tv_sec) * 8) / 3)]; + int nr; + char *fmt = RSTRING_PTR(op[1]); + int div = NUM2INT(op[2]); + + nr = snprintf(buf, sizeof(buf), fmt, + (int)tv->tv_sec, (int)(tv->tv_usec / div)); + assert(nr > 0 && nr < sizeof(buf)); + rb_str_buf_cat(c->log_buf, buf, nr); +} + +static void append_request_time_fmt(struct clogger *c, const VALUE *op) +{ + struct timeval now, d; + + gettimeofday(&now, NULL); + timersub(&now, &c->tv_start, &d); + append_tv(c, op, &d); +} + +static void append_time_fmt(struct clogger *c, const VALUE *op) +{ + struct timeval now; + + gettimeofday(&now, NULL); + append_tv(c, op, &now); +} + +static void append_request(struct clogger *c) +{ + VALUE tmp; + VALUE env = c->env; + + /* REQUEST_METHOD doesn't need escaping, Rack::Lint governs it */ + tmp = rb_hash_aref(env, g_REQUEST_METHOD); + rb_str_buf_append(c->log_buf, NIL_P(tmp) ? g_empty : tmp); + rb_str_buf_append(c->log_buf, g_space); + + /* broken clients can send " and other questionable URIs */ + tmp = rb_hash_aref(env, g_PATH_INFO); + rb_str_buf_append(c->log_buf, NIL_P(tmp) ? g_empty : byte_xs(tmp)); + + tmp = rb_hash_aref(env, g_QUERY_STRING); + if (RSTRING_LEN(tmp) != 0) { + rb_str_buf_append(c->log_buf, g_question_mark); + rb_str_buf_append(c->log_buf, byte_xs(tmp)); + } + rb_str_buf_append(c->log_buf, g_space); + + /* HTTP_VERSION can be injected by malicious clients */ + tmp = rb_hash_aref(env, g_HTTP_VERSION); + rb_str_buf_append(c->log_buf, NIL_P(tmp) ? g_empty : byte_xs(tmp)); +} + +static void append_request_length(struct clogger *c) +{ + VALUE tmp = rb_hash_aref(c->env, g_rack_input); + if (NIL_P(tmp)) { + rb_str_buf_append(c->log_buf, g_dash); + } else { + tmp = rb_funcall(tmp, size_id, 0); + rb_str_buf_append(c->log_buf, rb_funcall(tmp, to_s_id, 0)); + } +} + +static void append_time(struct clogger *c, enum clogger_opcode op, VALUE fmt) +{ + /* you'd have to be a moron to use formats this big... */ + char buf[sizeof("Saturday, November 01, 1970, 00:00:00 PM +0000")]; + size_t nr; + struct tm tmp; + time_t t = time(NULL); + + if (op == CL_OP_TIME_LOCAL) + localtime_r(&t, &tmp); + else if (op == CL_OP_TIME_UTC) + gmtime_r(&t, &tmp); + else + assert(0 && "unknown op"); + + nr = strftime(buf, sizeof(buf), RSTRING_PTR(fmt), &tmp); + if (nr == 0 || nr == sizeof(buf)) + rb_str_buf_append(c->log_buf, g_dash); + else + rb_str_buf_cat(c->log_buf, buf, nr); +} + +static void append_pid(struct clogger *c) +{ + char buf[(sizeof(pid_t) * 8) / 3 + 1]; + int nr = snprintf(buf, sizeof(buf), "%d", (int)getpid()); + + assert(nr > 0 && nr < sizeof(buf)); + rb_str_buf_cat(c->log_buf, buf, nr); +} + +static void append_eval(struct clogger *c, VALUE str) +{ + int state = -1; + VALUE rv = rb_eval_string_protect(RSTRING_PTR(str), &state); + + rv = state == 0 ? rb_obj_as_string(rv) : g_dash; + rb_str_buf_append(c->log_buf, rv); +} + +static void append_cookie(struct clogger *c, VALUE key) +{ + VALUE cookie; + + if (c->cookies == Qfalse) + c->cookies = rb_hash_aref(c->env, g_rack_request_cookie_hash); + + if (NIL_P(c->cookies)) { + cookie = g_dash; + } else { + cookie = rb_hash_aref(c->cookies, key); + if (NIL_P(cookie)) + cookie = g_dash; + } + rb_str_buf_append(c->log_buf, cookie); +} + +static void append_request_env(struct clogger *c, VALUE key) +{ + VALUE tmp = rb_hash_aref(c->env, key); + + tmp = NIL_P(tmp) ? g_dash : byte_xs(rb_obj_as_string(tmp)); + rb_str_buf_append(c->log_buf, tmp); +} + +static void special_var(struct clogger *c, enum clogger_special var) +{ + switch (var) { + case CL_SP_body_bytes_sent: + append_body_bytes_sent(c); + break; + case CL_SP_status: + append_status(c, RARRAY_PTR(c->response)[0]); + break; + case CL_SP_request: + append_request(c); + break; + case CL_SP_request_length: + append_request_length(c); + break; + case CL_SP_response_length: + if (c->body_bytes_sent == 0) + rb_str_buf_append(c->log_buf, g_dash); + else + append_body_bytes_sent(c); + break; + case CL_SP_ip: + append_ip(c); + break; + case CL_SP_pid: + append_pid(c); + } +} + +static VALUE cwrite(struct clogger *c) +{ + const VALUE ops = sent_headers_ops(c); + const VALUE *ary = RARRAY_PTR(ops); + long i = RARRAY_LEN(ops); + VALUE dst = c->log_buf; + + rb_str_set_len(dst, 0); + + for (; --i >= 0; ary++) { + const VALUE *op = RARRAY_PTR(*ary); + enum clogger_opcode opcode = NUM2INT(op[0]); + + switch (opcode) { + case CL_OP_LITERAL: + rb_str_buf_append(dst, op[1]); + break; + case CL_OP_REQUEST: + append_request_env(c, op[1]); + break; + case CL_OP_RESPONSE: + /* headers we found already got swapped for literals */ + rb_str_buf_append(dst, g_dash); + break; + case CL_OP_SPECIAL: + special_var(c, NUM2INT(op[1])); + break; + case CL_OP_EVAL: + append_eval(c, op[1]); + break; + case CL_OP_TIME_LOCAL: + case CL_OP_TIME_UTC: + append_time(c, opcode, op[1]); + break; + case CL_OP_REQUEST_TIME: + append_request_time_fmt(c, op); + break; + case CL_OP_TIME: + append_time_fmt(c, op); + break; + case CL_OP_COOKIE: + append_cookie(c, op[1]); + break; + } + } + + if (c->fd >= 0) { + write_full(c->fd, RSTRING_PTR(dst), RSTRING_LEN(dst)); + } else { + VALUE logger = c->logger; + + if (NIL_P(logger)) + logger = rb_hash_aref(c->env, g_rack_errors); + rb_funcall(logger, ltlt_id, 1, dst); + } + + return Qnil; +} + +/** + * call-seq: + * Clogger.new(app, :logger => $stderr, :format => string) => obj + * + * Creates a new Clogger object that wraps +app+. +:logger+ may + * be any object that responds to the "<<" method with a string argument. + */ +static VALUE clogger_init(int argc, VALUE *argv, VALUE self) +{ + struct clogger *c = clogger_get(self); + VALUE o = Qnil; + VALUE fmt = rb_const_get(mFormat, rb_intern("Common")); + + rb_scan_args(argc, argv, "11", &c->app, &o); + c->fd = -1; + c->logger = Qnil; + c->reentrant = -1; /* auto-detect */ + + if (TYPE(o) == T_HASH) { + VALUE tmp; + + c->logger = rb_hash_aref(o, ID2SYM(rb_intern("logger"))); + if (!NIL_P(c->logger)) + c->fd = raw_fd(rb_rescue(obj_fileno, c->logger, 0, 0)); + + tmp = rb_hash_aref(o, ID2SYM(rb_intern("format"))); + if (!NIL_P(tmp)) + fmt = tmp; + } + + init_buffers(c); + c->fmt_ops = rb_funcall(self, rb_intern("compile_format"), 1, fmt); + + if (Qtrue == rb_funcall(self, rb_intern("need_response_headers?"), + 1, c->fmt_ops)) + c->need_resp = 1; + if (Qtrue == rb_funcall(self, rb_intern("need_wrap_body?"), + 1, c->fmt_ops)) + c->wrap_body = 1; + + return self; +} + +static VALUE body_iter_i(VALUE str, VALUE memop) +{ + off_t *len = (off_t *)memop; + + *len += RSTRING_LEN(str); + + return rb_yield(str); +} + +static VALUE wrap_each(struct clogger *c) +{ + VALUE body = RARRAY_PTR(c->response)[2]; + + rb_need_block(); + c->body_bytes_sent = 0; + rb_iterate(rb_each, body, body_iter_i, (VALUE)&c->body_bytes_sent); + + return body; +} + +/** + * call-seq: + * clogger.each { |part| socket.write(part) } + * + * Delegates the body#each call to the underlying +body+ object + * while tracking the number of bytes yielded. This will log + * the request. + */ +static VALUE clogger_each(VALUE self) +{ + struct clogger *c = clogger_get(self); + + return rb_ensure(wrap_each, (VALUE)c, cwrite, (VALUE)c); +} + +/** + * call-seq: + * clogger.close + * + * Delegates the body#close call to the underlying +body+ object. + * This is only used when Clogger is wrapping the +body+ of a Rack + * response and should be automatically called by the web server. + */ +static VALUE clogger_close(VALUE self) +{ + struct clogger *c = clogger_get(self); + + return rb_funcall(RARRAY_PTR(c->response)[2], close_id, 0); +} + +/* :nodoc: */ +static VALUE clogger_fileno(VALUE self) +{ + struct clogger *c = clogger_get(self); + + return c->fd < 0 ? Qnil : INT2NUM(c->fd); +} + +static void ccall(struct clogger *c, VALUE env) +{ + gettimeofday(&c->tv_start, NULL); + c->env = env; + c->cookies = Qfalse; + c->response = rb_funcall(c->app, call_id, 1, env); +} + +/* + * call-seq: + * clogger.call(env) => [ status, headers, body ] + * + * calls the wrapped Rack application with +env+, returns the + * [status, headers, body ] tuplet required by Rack. + */ +static VALUE clogger_call(VALUE self, VALUE env) +{ + struct clogger *c = clogger_get(self); + + if (c->wrap_body) { + VALUE tmp; + + if (c->reentrant < 0) { + tmp = rb_hash_aref(env, g_rack_multithread); + c->reentrant = Qfalse == tmp ? 0 : 1; + } + if (c->reentrant) { + self = rb_obj_dup(self); + c = clogger_get(self); + } + + ccall(c, env); + tmp = rb_ary_dup(c->response); + rb_ary_store(tmp, 2, self); + return tmp; + } + + ccall(c, env); + cwrite(c); + + return c->response; +} + +/* :nodoc */ +static VALUE clogger_init_copy(VALUE clone, VALUE orig) +{ + struct clogger *a = clogger_get(orig); + struct clogger *b = clogger_get(clone); + + memcpy(b, a, sizeof(struct clogger)); + init_buffers(b); + + return clone; +} + +#define CONST_GLOBAL_STR2(var, val) do { \ + g_##var = rb_obj_freeze(rb_str_new(val, sizeof(val) - 1)); \ + rb_global_variable(&g_##var); \ +} while (0) + +#define CONST_GLOBAL_STR(val) CONST_GLOBAL_STR2(val, #val) + +void Init_clogger_ext(void) +{ + ltlt_id = rb_intern("<<"); + call_id = rb_intern("call"); + each_id = rb_intern("each"); + close_id = rb_intern("close"); + to_i_id = rb_intern("to_i"); + to_s_id = rb_intern("to_s"); + size_id = rb_intern("size"); + cClogger = rb_define_class("Clogger", rb_cObject); + mFormat = rb_define_module_under(cClogger, "Format"); + rb_define_alloc_func(cClogger, clogger_alloc); + rb_define_method(cClogger, "initialize", clogger_init, -1); + rb_define_method(cClogger, "initialize_copy", clogger_init_copy, 1); + rb_define_method(cClogger, "call", clogger_call, 1); + rb_define_method(cClogger, "each", clogger_each, 0); + rb_define_method(cClogger, "close", clogger_close, 0); + rb_define_method(cClogger, "fileno", clogger_fileno, 0); + rb_define_method(cClogger, "wrap_body?", clogger_wrap_body, 0); + rb_define_method(cClogger, "reentrant?", clogger_reentrant, 0); + CONST_GLOBAL_STR(REMOTE_ADDR); + CONST_GLOBAL_STR(HTTP_X_FORWARDED_FOR); + CONST_GLOBAL_STR(REQUEST_METHOD); + CONST_GLOBAL_STR(PATH_INFO); + CONST_GLOBAL_STR(QUERY_STRING); + CONST_GLOBAL_STR(HTTP_VERSION); + CONST_GLOBAL_STR2(rack_errors, "rack.errors"); + CONST_GLOBAL_STR2(rack_input, "rack.input"); + CONST_GLOBAL_STR2(rack_multithread, "rack.multithread"); + CONST_GLOBAL_STR2(dash, "-"); + CONST_GLOBAL_STR2(empty, ""); + CONST_GLOBAL_STR2(space, " "); + CONST_GLOBAL_STR2(question_mark, "?"); + CONST_GLOBAL_STR2(rack_request_cookie_hash, "rack.request.cookie_hash"); +} diff --git a/ext/clogger_ext/extconf.rb b/ext/clogger_ext/extconf.rb new file mode 100644 index 0000000..8cc8b5e --- /dev/null +++ b/ext/clogger_ext/extconf.rb @@ -0,0 +1,12 @@ +require 'mkmf' + +if have_header('fcntl.h') + have_macro('F_GETFL', %w(fcntl.h)) + have_macro('O_NONBLOCK', %w(unistd.h fcntl.h)) +end + +have_func('localtime_r', 'time.h') or abort "localtime_r needed" +have_func('gmtime_r', 'time.h') or abort "gmtime_r needed" +have_func('rb_str_set_len', 'ruby.h') +dir_config('clogger_ext') +create_makefile('clogger_ext') diff --git a/ext/clogger_ext/ruby_1_9_compat.h b/ext/clogger_ext/ruby_1_9_compat.h new file mode 100644 index 0000000..e0ba4ac --- /dev/null +++ b/ext/clogger_ext/ruby_1_9_compat.h @@ -0,0 +1,23 @@ +/* Ruby 1.8.6+ macros (for compatibility with Ruby 1.9) */ +#ifndef RSTRING_PTR +# define RSTRING_PTR(s) (RSTRING(s)->ptr) +#endif +#ifndef RSTRING_LEN +# define RSTRING_LEN(s) (RSTRING(s)->len) +#endif +#ifndef RARRAY_PTR +# define RARRAY_PTR(s) (RARRAY(s)->ptr) +#endif +#ifndef RARRAY_LEN +# define RARRAY_LEN(s) (RARRAY(s)->len) +#endif + +#ifndef HAVE_RB_STR_SET_LEN +/* this is taken from Ruby 1.8.7, 1.8.6 may not have it */ +static void rb_18_str_set_len(VALUE str, long len) +{ + RSTRING(str)->len = len; + RSTRING(str)->ptr[len] = '\0'; +} +#define rb_str_set_len(str,len) rb_18_str_set_len(str,len) +#endif diff --git a/lib/clogger.rb b/lib/clogger.rb new file mode 100644 index 0000000..0e4148e --- /dev/null +++ b/lib/clogger.rb @@ -0,0 +1,133 @@ +# -*- encoding: binary -*- +class Clogger + VERSION = '0.0.1' + + OP_LITERAL = 0 + OP_REQUEST = 1 + OP_RESPONSE = 2 + OP_SPECIAL = 3 + OP_EVAL = 4 + OP_TIME_LOCAL = 5 + OP_TIME_UTC = 6 + OP_REQUEST_TIME = 7 + OP_TIME = 8 + OP_COOKIE = 9 + + # support nginx variables that are less customizable than our own + ALIASES = { + '$request_time' => '$request_time{3}', + '$time_local' => '$time_local{%d/%b/%Y:%H:%M:%S %z}', + '$msec' => '$time{3}', + '$usec' => '$time{6}', + } + + SPECIAL_VARS = { + :body_bytes_sent => 0, + :status => 1, + :request => 2, # REQUEST_METHOD PATH_INFO?QUERY_STRING HTTP_VERSION + :request_length => 3, # env['rack.input'].size + :response_length => 4, # like body_bytes_sent, except "-" instead of "0" + :ip => 5, # HTTP_X_FORWARDED_FOR || REMOTE_ADDR || - + :pid => 6, # getpid() + } + +private + + CGI_ENV = Regexp.new('\A\$(' << + %w(remote_addr remote_ident remote_user + path_info query_string script_name + server_name server_port).join('|') << ')\z').freeze + + SCAN = /([^$]*)(\$+(?:env\{\w+(?:\.[\w\.]+)?\}| + e\{[^\}]+\}| + (?:request_)?time\{\d+\}| + time_(?:utc|local)\{[^\}]+\}| + \w*))?([^$]*)/x + + def compile_format(str) + rv = [] + str.scan(SCAN).each do |pre,tok,post| + rv << [ OP_LITERAL, pre ] if pre && pre != "" + + unless tok.nil? + if tok.sub!(/\A(\$+)\$/, '$') + rv << [ OP_LITERAL, $1.dup ] + end + + compat = ALIASES[tok] and tok = compat + + case tok + when /\A(\$*)\z/ + rv << [ OP_LITERAL, $1.dup ] + when /\A\$env\{(\w+(?:\.[\w\.]+))\}\z/ + rv << [ OP_REQUEST, $1.freeze ] + when /\A\$e\{([^\}]+)\}\z/ + rv << [ OP_EVAL, $1.dup ] + when /\A\$cookie_(\w+)\z/ + rv << [ OP_COOKIE, $1.dup.freeze ] + when CGI_ENV, /\A\$(http_\w+)\z/ + rv << [ OP_REQUEST, $1.upcase.freeze ] + when /\A\$sent_http_(\w+)\z/ + rv << [ OP_RESPONSE, $1.downcase.tr('_','-').freeze ] + when /\A\$time_local\{([^\}]+)\}\z/ + rv << [ OP_TIME_LOCAL, $1.dup ] + when /\A\$time_utc\{([^\}]+)\}\z/ + rv << [ OP_TIME_UTC, $1.dup ] + when /\A\$time\{(\d+)\}\z/ + rv << [ OP_TIME, *usec_conv_pair(tok, $1.to_i) ] + when /\A\$request_time\{(\d+)\}\z/ + rv << [ OP_REQUEST_TIME, *usec_conv_pair(tok, $1.to_i) ] + else + tok_sym = tok[1..-1].to_sym + if special_code = SPECIAL_VARS[tok_sym] + rv << [ OP_SPECIAL, special_code ] + else + raise ArgumentError, "unable to make sense of token: #{tok}" + end + end + end + + rv << [ OP_LITERAL, post ] if post && post != "" + end + + # auto-append a newline + last = rv.last or return rv + op = last.first + if (op == OP_LITERAL && /\n\z/ !~ last.last) || op != OP_LITERAL + rv << [ OP_LITERAL, "\n" ] + end + + rv + end + + def usec_conv_pair(tok, prec) + if prec == 0 + [ "%d", 1 ] # stupid... + elsif prec > 6 + raise ArgumentError, "#{tok}: too high precision: #{prec} (max=6)" + else + [ "%d.%0#{prec}d", 10 ** (6 - prec) ] + end + end + + def need_response_headers?(fmt_ops) + fmt_ops.any? { |op| OP_RESPONSE == op[0] } + end + + def need_wrap_body?(fmt_ops) + fmt_ops.any? do |op| + (OP_REQUEST_TIME == op[0]) || (OP_SPECIAL == op[0] && + (SPECIAL_VARS[:body_bytes_sent] == op[1] || + SPECIAL_VARS[:response_length] == op[1])) + end + end + +end + +require 'clogger/format' + +begin + require 'clogger_ext' +rescue LoadError + require 'clogger/pure' +end diff --git a/lib/clogger/format.rb b/lib/clogger/format.rb new file mode 100644 index 0000000..9e4f59f --- /dev/null +++ b/lib/clogger/format.rb @@ -0,0 +1,25 @@ +# -*- encoding: binary -*- + +class Clogger + + # predefined log formats in wide use + module Format + # common log format used by Apache: + # http://httpd.apache.org/docs/2.2/logs.html + Common = "$remote_addr - $remote_user [$time_local] " \ + '"$request" $status $response_length'.freeze + + # combined log format used by Apache: + # http://httpd.apache.org/docs/2.2/logs.html + Combined = %Q|#{Common} "$http_referer" "$http_user_agent"|.freeze + + # combined log format used by nginx: + # http://wiki.nginx.org/NginxHttpLogModule + NginxCombined = Combined.gsub(/response_length/, 'body_bytes_sent').freeze + + # log format used by Rack 1.0 + Rack_1_0 = "$ip - $remote_user [$time_local{%d/%b/%Y %H:%M:%S}] " \ + '"$request" $status $response_length $request_time{4}'.freeze + end + +end diff --git a/lib/clogger/pure.rb b/lib/clogger/pure.rb new file mode 100644 index 0000000..11c03f4 --- /dev/null +++ b/lib/clogger/pure.rb @@ -0,0 +1,126 @@ +# -*- encoding: binary -*- +# :stopdoc: +# +# Not at all optimized for performance, this was written based on +# the original C extension code so it's not very Ruby-ish... +class Clogger + + def initialize(app, opts = {}) + @app = app + @logger = opts[:logger] + @fmt_ops = compile_format(opts[:format] || Format::Common) + @wrap_body = need_wrap_body?(@fmt_ops) + @reentrant = nil + @body_bytes_sent = 0 + end + + def call(env) + @start = Time.now + status, headers, body = @app.call(env) + if wrap_body? + @reentrant = env['rack.multithread'] + @env, @status, @headers, @body = env, status, headers, body + return [ status, headers, reentrant? ? self.dup : self ] + end + log(env, status, headers) + [ status, headers, body ] + end + + def each + @body_bytes_sent = 0 + @body.each do |part| + @body_bytes_sent += part.size + yield part + end + ensure + log(@env, @status, @headers) + end + + def close + @body.close + end + + def reentrant? + @reentrant + end + + def wrap_body? + @wrap_body + end + + def fileno + @logger.fileno rescue nil + end + +private + + def byte_xs(s) + s = s.dup + s.force_encoding(Encoding::BINARY) if defined?(Encoding::BINARY) + s.gsub!(/(['"\x00-\x1f])/) { |x| "\\x#{$1.unpack('H2').first}" } + s + end + + SPECIAL_RMAP = SPECIAL_VARS.inject([]) { |ary, (k,v)| ary[v] = k; ary } + + def special_var(special_nr, env, status, headers) + case SPECIAL_RMAP[special_nr] + when :body_bytes_sent + @body_bytes_sent.to_s + when :status + status = status.to_i + status >= 100 && status <= 999 ? ('%03d' % status) : '-' + when :request + qs = env['QUERY_STRING'] + qs.empty? or qs = "?#{byte_xs(qs)}" + "#{env['REQUEST_METHOD']} " \ + "#{byte_xs(env['PATH_INFO'])}#{qs} " \ + "#{byte_xs(env['HTTP_VERSION'])}" + when :request_length + env['rack.input'].size.to_s + when :response_length + @body_bytes_sent == 0 ? '-' : @body_bytes_sent.to_s + when :ip + xff = env['HTTP_X_FORWARDED_FOR'] and return byte_xs(xff) + env['REMOTE_ADDR'] || '-' + when :pid + $$.to_s + else + raise "EDOOFUS #{special_nr}" + end + end + + def time_format(sec, usec, format, div) + format % [ sec, usec / div ] + end + + def log(env, status, headers) + (@logger || env['rack.errors']) << @fmt_ops.map { |op| + case op[0] + when OP_LITERAL; op[1] + when OP_REQUEST; byte_xs(env[op[1]] || "-") + when OP_RESPONSE; byte_xs(get_sent_header(headers, op[1])) + when OP_SPECIAL; special_var(op[1], env, status, headers) + when OP_EVAL; eval(op[1]).to_s rescue "-" + when OP_TIME_LOCAL; Time.now.strftime(op[1]) + when OP_TIME_UTC; Time.now.utc.strftime(op[1]) + when OP_REQUEST_TIME + t = Time.now - @start + time_format(t.to_i, (t - t.to_i) * 1000000, op[1], op[2]) + when OP_TIME + t = Time.now + time_format(t.sec, t.usec, op[1], op[2]) + when OP_COOKIE + (env['rack.request.cookie_hash'][op[1]] rescue "-") || "-" + else + raise "EDOOFUS #{op.inspect}" + end + }.join('') + end + + def get_sent_header(headers, match) + headers.each { |key, value| match == key.downcase and return value } + "-" + end + +end diff --git a/setup.rb b/setup.rb new file mode 100644 index 0000000..9f0c826 --- /dev/null +++ b/setup.rb @@ -0,0 +1,1585 @@ +# +# setup.rb +# +# Copyright (c) 2000-2005 Minero Aoki +# +# This program is free software. +# You can distribute/modify this program under the terms of +# the GNU LGPL, Lesser General Public License version 2.1. +# + +unless Enumerable.method_defined?(:map) # Ruby 1.4.6 + module Enumerable + alias map collect + end +end + +unless File.respond_to?(:read) # Ruby 1.6 + def File.read(fname) + open(fname) {|f| + return f.read + } + end +end + +unless Errno.const_defined?(:ENOTEMPTY) # Windows? + module Errno + class ENOTEMPTY + # We do not raise this exception, implementation is not needed. + end + end +end + +def File.binread(fname) + open(fname, 'rb') {|f| + return f.read + } +end + +# for corrupted Windows' stat(2) +def File.dir?(path) + File.directory?((path[-1,1] == '/') ? path : path + '/') +end + + +class ConfigTable + + include Enumerable + + def initialize(rbconfig) + @rbconfig = rbconfig + @items = [] + @table = {} + # options + @install_prefix = nil + @config_opt = nil + @verbose = true + @no_harm = false + end + + attr_accessor :install_prefix + attr_accessor :config_opt + + attr_writer :verbose + + def verbose? + @verbose + end + + attr_writer :no_harm + + def no_harm? + @no_harm + end + + def [](key) + lookup(key).resolve(self) + end + + def []=(key, val) + lookup(key).set val + end + + def names + @items.map {|i| i.name } + end + + def each(&block) + @items.each(&block) + end + + def key?(name) + @table.key?(name) + end + + def lookup(name) + @table[name] or setup_rb_error "no such config item: #{name}" + end + + def add(item) + @items.push item + @table[item.name] = item + end + + def remove(name) + item = lookup(name) + @items.delete_if {|i| i.name == name } + @table.delete_if {|name, i| i.name == name } + item + end + + def load_script(path, inst = nil) + if File.file?(path) + MetaConfigEnvironment.new(self, inst).instance_eval File.read(path), path + end + end + + def savefile + '.config' + end + + def load_savefile + begin + File.foreach(savefile()) do |line| + k, v = *line.split(/=/, 2) + self[k] = v.strip + end + rescue Errno::ENOENT + setup_rb_error $!.message + "\n#{File.basename($0)} config first" + end + end + + def save + @items.each {|i| i.value } + File.open(savefile(), 'w') {|f| + @items.each do |i| + f.printf "%s=%s\n", i.name, i.value if i.value? and i.value + end + } + end + + def load_standard_entries + standard_entries(@rbconfig).each do |ent| + add ent + end + end + + def standard_entries(rbconfig) + c = rbconfig + + rubypath = File.join(c['bindir'], c['ruby_install_name'] + c['EXEEXT']) + + major = c['MAJOR'].to_i + minor = c['MINOR'].to_i + teeny = c['TEENY'].to_i + version = "#{major}.#{minor}" + + # ruby ver. >= 1.4.4? + newpath_p = ((major >= 2) or + ((major == 1) and + ((minor >= 5) or + ((minor == 4) and (teeny >= 4))))) + + if c['rubylibdir'] + # V > 1.6.3 + libruby = "#{c['prefix']}/lib/ruby" + librubyver = c['rubylibdir'] + librubyverarch = c['archdir'] + siteruby = c['sitedir'] + siterubyver = c['sitelibdir'] + siterubyverarch = c['sitearchdir'] + elsif newpath_p + # 1.4.4 <= V <= 1.6.3 + libruby = "#{c['prefix']}/lib/ruby" + librubyver = "#{c['prefix']}/lib/ruby/#{version}" + librubyverarch = "#{c['prefix']}/lib/ruby/#{version}/#{c['arch']}" + siteruby = c['sitedir'] + siterubyver = "$siteruby/#{version}" + siterubyverarch = "$siterubyver/#{c['arch']}" + else + # V < 1.4.4 + libruby = "#{c['prefix']}/lib/ruby" + librubyver = "#{c['prefix']}/lib/ruby/#{version}" + librubyverarch = "#{c['prefix']}/lib/ruby/#{version}/#{c['arch']}" + siteruby = "#{c['prefix']}/lib/ruby/#{version}/site_ruby" + siterubyver = siteruby + siterubyverarch = "$siterubyver/#{c['arch']}" + end + parameterize = lambda {|path| + path.sub(/\A#{Regexp.quote(c['prefix'])}/, '$prefix') + } + + if arg = c['configure_args'].split.detect {|arg| /--with-make-prog=/ =~ arg } + makeprog = arg.sub(/'/, '').split(/=/, 2)[1] + else + makeprog = 'make' + end + + [ + ExecItem.new('installdirs', 'std/site/home', + 'std: install under libruby; site: install under site_ruby; home: install under $HOME')\ + {|val, table| + case val + when 'std' + table['rbdir'] = '$librubyver' + table['sodir'] = '$librubyverarch' + when 'site' + table['rbdir'] = '$siterubyver' + table['sodir'] = '$siterubyverarch' + when 'home' + setup_rb_error '$HOME was not set' unless ENV['HOME'] + table['prefix'] = ENV['HOME'] + table['rbdir'] = '$libdir/ruby' + table['sodir'] = '$libdir/ruby' + end + }, + PathItem.new('prefix', 'path', c['prefix'], + 'path prefix of target environment'), + PathItem.new('bindir', 'path', parameterize.call(c['bindir']), + 'the directory for commands'), + PathItem.new('libdir', 'path', parameterize.call(c['libdir']), + 'the directory for libraries'), + PathItem.new('datadir', 'path', parameterize.call(c['datadir']), + 'the directory for shared data'), + PathItem.new('mandir', 'path', parameterize.call(c['mandir']), + 'the directory for man pages'), + PathItem.new('sysconfdir', 'path', parameterize.call(c['sysconfdir']), + 'the directory for system configuration files'), + PathItem.new('localstatedir', 'path', parameterize.call(c['localstatedir']), + 'the directory for local state data'), + PathItem.new('libruby', 'path', libruby, + 'the directory for ruby libraries'), + PathItem.new('librubyver', 'path', librubyver, + 'the directory for standard ruby libraries'), + PathItem.new('librubyverarch', 'path', librubyverarch, + 'the directory for standard ruby extensions'), + PathItem.new('siteruby', 'path', siteruby, + 'the directory for version-independent aux ruby libraries'), + PathItem.new('siterubyver', 'path', siterubyver, + 'the directory for aux ruby libraries'), + PathItem.new('siterubyverarch', 'path', siterubyverarch, + 'the directory for aux ruby binaries'), + PathItem.new('rbdir', 'path', '$siterubyver', + 'the directory for ruby scripts'), + PathItem.new('sodir', 'path', '$siterubyverarch', + 'the directory for ruby extentions'), + PathItem.new('rubypath', 'path', rubypath, + 'the path to set to #! line'), + ProgramItem.new('rubyprog', 'name', rubypath, + 'the ruby program using for installation'), + ProgramItem.new('makeprog', 'name', makeprog, + 'the make program to compile ruby extentions'), + SelectItem.new('shebang', 'all/ruby/never', 'ruby', + 'shebang line (#!) editing mode'), + BoolItem.new('without-ext', 'yes/no', 'no', + 'does not compile/install ruby extentions') + ] + end + private :standard_entries + + def load_multipackage_entries + multipackage_entries().each do |ent| + add ent + end + end + + def multipackage_entries + [ + PackageSelectionItem.new('with', 'name,name...', '', 'ALL', + 'package names that you want to install'), + PackageSelectionItem.new('without', 'name,name...', '', 'NONE', + 'package names that you do not want to install') + ] + end + private :multipackage_entries + + ALIASES = { + 'std-ruby' => 'librubyver', + 'stdruby' => 'librubyver', + 'rubylibdir' => 'librubyver', + 'archdir' => 'librubyverarch', + 'site-ruby-common' => 'siteruby', # For backward compatibility + 'site-ruby' => 'siterubyver', # For backward compatibility + 'bin-dir' => 'bindir', + 'bin-dir' => 'bindir', + 'rb-dir' => 'rbdir', + 'so-dir' => 'sodir', + 'data-dir' => 'datadir', + 'ruby-path' => 'rubypath', + 'ruby-prog' => 'rubyprog', + 'ruby' => 'rubyprog', + 'make-prog' => 'makeprog', + 'make' => 'makeprog' + } + + def fixup + ALIASES.each do |ali, name| + @table[ali] = @table[name] + end + @items.freeze + @table.freeze + @options_re = /\A--(#{@table.keys.join('|')})(?:=(.*))?\z/ + end + + def parse_opt(opt) + m = @options_re.match(opt) or setup_rb_error "config: unknown option #{opt}" + m.to_a[1,2] + end + + def dllext + @rbconfig['DLEXT'] + end + + def value_config?(name) + lookup(name).value? + end + + class Item + def initialize(name, template, default, desc) + @name = name.freeze + @template = template + @value = default + @default = default + @description = desc + end + + attr_reader :name + attr_reader :description + + attr_accessor :default + alias help_default default + + def help_opt + "--#{@name}=#{@template}" + end + + def value? + true + end + + def value + @value + end + + def resolve(table) + @value.gsub(%r<\$([^/]+)>) { table[$1] } + end + + def set(val) + @value = check(val) + end + + private + + def check(val) + setup_rb_error "config: --#{name} requires argument" unless val + val + end + end + + class BoolItem < Item + def config_type + 'bool' + end + + def help_opt + "--#{@name}" + end + + private + + def check(val) + return 'yes' unless val + case val + when /\Ay(es)?\z/i, /\At(rue)?\z/i then 'yes' + when /\An(o)?\z/i, /\Af(alse)\z/i then 'no' + else + setup_rb_error "config: --#{@name} accepts only yes/no for argument" + end + end + end + + class PathItem < Item + def config_type + 'path' + end + + private + + def check(path) + setup_rb_error "config: --#{@name} requires argument" unless path + path[0,1] == '$' ? path : File.expand_path(path) + end + end + + class ProgramItem < Item + def config_type + 'program' + end + end + + class SelectItem < Item + def initialize(name, selection, default, desc) + super + @ok = selection.split('/') + end + + def config_type + 'select' + end + + private + + def check(val) + unless @ok.include?(val.strip) + setup_rb_error "config: use --#{@name}=#{@template} (#{val})" + end + val.strip + end + end + + class ExecItem < Item + def initialize(name, selection, desc, &block) + super name, selection, nil, desc + @ok = selection.split('/') + @action = block + end + + def config_type + 'exec' + end + + def value? + false + end + + def resolve(table) + setup_rb_error "$#{name()} wrongly used as option value" + end + + undef set + + def evaluate(val, table) + v = val.strip.downcase + unless @ok.include?(v) + setup_rb_error "invalid option --#{@name}=#{val} (use #{@template})" + end + @action.call v, table + end + end + + class PackageSelectionItem < Item + def initialize(name, template, default, help_default, desc) + super name, template, default, desc + @help_default = help_default + end + + attr_reader :help_default + + def config_type + 'package' + end + + private + + def check(val) + unless File.dir?("packages/#{val}") + setup_rb_error "config: no such package: #{val}" + end + val + end + end + + class MetaConfigEnvironment + def initialize(config, installer) + @config = config + @installer = installer + end + + def config_names + @config.names + end + + def config?(name) + @config.key?(name) + end + + def bool_config?(name) + @config.lookup(name).config_type == 'bool' + end + + def path_config?(name) + @config.lookup(name).config_type == 'path' + end + + def value_config?(name) + @config.lookup(name).config_type != 'exec' + end + + def add_config(item) + @config.add item + end + + def add_bool_config(name, default, desc) + @config.add BoolItem.new(name, 'yes/no', default ? 'yes' : 'no', desc) + end + + def add_path_config(name, default, desc) + @config.add PathItem.new(name, 'path', default, desc) + end + + def set_config_default(name, default) + @config.lookup(name).default = default + end + + def remove_config(name) + @config.remove(name) + end + + # For only multipackage + def packages + raise '[setup.rb fatal] multi-package metaconfig API packages() called for single-package; contact application package vendor' unless @installer + @installer.packages + end + + # For only multipackage + def declare_packages(list) + raise '[setup.rb fatal] multi-package metaconfig API declare_packages() called for single-package; contact application package vendor' unless @installer + @installer.packages = list + end + end + +end # class ConfigTable + + +# This module requires: #verbose?, #no_harm? +module FileOperations + + def mkdir_p(dirname, prefix = nil) + dirname = prefix + File.expand_path(dirname) if prefix + $stderr.puts "mkdir -p #{dirname}" if verbose? + return if no_harm? + + # Does not check '/', it's too abnormal. + dirs = File.expand_path(dirname).split(%r<(?=/)>) + if /\A[a-z]:\z/i =~ dirs[0] + disk = dirs.shift + dirs[0] = disk + dirs[0] + end + dirs.each_index do |idx| + path = dirs[0..idx].join('') + Dir.mkdir path unless File.dir?(path) + end + end + + def rm_f(path) + $stderr.puts "rm -f #{path}" if verbose? + return if no_harm? + force_remove_file path + end + + def rm_rf(path) + $stderr.puts "rm -rf #{path}" if verbose? + return if no_harm? + remove_tree path + end + + def remove_tree(path) + if File.symlink?(path) + remove_file path + elsif File.dir?(path) + remove_tree0 path + else + force_remove_file path + end + end + + def remove_tree0(path) + Dir.foreach(path) do |ent| + next if ent == '.' + next if ent == '..' + entpath = "#{path}/#{ent}" + if File.symlink?(entpath) + remove_file entpath + elsif File.dir?(entpath) + remove_tree0 entpath + else + force_remove_file entpath + end + end + begin + Dir.rmdir path + rescue Errno::ENOTEMPTY + # directory may not be empty + end + end + + def move_file(src, dest) + force_remove_file dest + begin + File.rename src, dest + rescue + File.open(dest, 'wb') {|f| + f.write File.binread(src) + } + File.chmod File.stat(src).mode, dest + File.unlink src + end + end + + def force_remove_file(path) + begin + remove_file path + rescue + end + end + + def remove_file(path) + File.chmod 0777, path + File.unlink path + end + + def install(from, dest, mode, prefix = nil) + $stderr.puts "install #{from} #{dest}" if verbose? + return if no_harm? + + realdest = prefix ? prefix + File.expand_path(dest) : dest + realdest = File.join(realdest, File.basename(from)) if File.dir?(realdest) + str = File.binread(from) + if diff?(str, realdest) + verbose_off { + rm_f realdest if File.exist?(realdest) + } + File.open(realdest, 'wb') {|f| + f.write str + } + File.chmod mode, realdest + + File.open("#{objdir_root()}/InstalledFiles", 'a') {|f| + if prefix + f.puts realdest.sub(prefix, '') + else + f.puts realdest + end + } + end + end + + def diff?(new_content, path) + return true unless File.exist?(path) + new_content != File.binread(path) + end + + def command(*args) + $stderr.puts args.join(' ') if verbose? + system(*args) or raise RuntimeError, + "system(#{args.map{|a| a.inspect }.join(' ')}) failed" + end + + def ruby(*args) + command config('rubyprog'), *args + end + + def make(task = nil) + command(*[config('makeprog'), task].compact) + end + + def extdir?(dir) + File.exist?("#{dir}/MANIFEST") or File.exist?("#{dir}/extconf.rb") + end + + def files_of(dir) + Dir.open(dir) {|d| + return d.select {|ent| File.file?("#{dir}/#{ent}") } + } + end + + DIR_REJECT = %w( . .. CVS SCCS RCS CVS.adm .svn ) + + def directories_of(dir) + Dir.open(dir) {|d| + return d.select {|ent| File.dir?("#{dir}/#{ent}") } - DIR_REJECT + } + end + +end + + +# This module requires: #srcdir_root, #objdir_root, #relpath +module HookScriptAPI + + def get_config(key) + @config[key] + end + + alias config get_config + + # obsolete: use metaconfig to change configuration + def set_config(key, val) + @config[key] = val + end + + # + # srcdir/objdir (works only in the package directory) + # + + def curr_srcdir + "#{srcdir_root()}/#{relpath()}" + end + + def curr_objdir + "#{objdir_root()}/#{relpath()}" + end + + def srcfile(path) + "#{curr_srcdir()}/#{path}" + end + + def srcexist?(path) + File.exist?(srcfile(path)) + end + + def srcdirectory?(path) + File.dir?(srcfile(path)) + end + + def srcfile?(path) + File.file?(srcfile(path)) + end + + def srcentries(path = '.') + Dir.open("#{curr_srcdir()}/#{path}") {|d| + return d.to_a - %w(. ..) + } + end + + def srcfiles(path = '.') + srcentries(path).select {|fname| + File.file?(File.join(curr_srcdir(), path, fname)) + } + end + + def srcdirectories(path = '.') + srcentries(path).select {|fname| + File.dir?(File.join(curr_srcdir(), path, fname)) + } + end + +end + + +class ToplevelInstaller + + Version = '3.4.1' + Copyright = 'Copyright (c) 2000-2005 Minero Aoki' + + TASKS = [ + [ 'all', 'do config, setup, then install' ], + [ 'config', 'saves your configurations' ], + [ 'show', 'shows current configuration' ], + [ 'setup', 'compiles ruby extentions and others' ], + [ 'install', 'installs files' ], + [ 'test', 'run all tests in test/' ], + [ 'clean', "does `make clean' for each extention" ], + [ 'distclean',"does `make distclean' for each extention" ] + ] + + def ToplevelInstaller.invoke + config = ConfigTable.new(load_rbconfig()) + config.load_standard_entries + config.load_multipackage_entries if multipackage? + config.fixup + klass = (multipackage?() ? ToplevelInstallerMulti : ToplevelInstaller) + klass.new(File.dirname($0), config).invoke + end + + def ToplevelInstaller.multipackage? + File.dir?(File.dirname($0) + '/packages') + end + + def ToplevelInstaller.load_rbconfig + if arg = ARGV.detect {|arg| /\A--rbconfig=/ =~ arg } + ARGV.delete(arg) + load File.expand_path(arg.split(/=/, 2)[1]) + $".push 'rbconfig.rb' + else + require 'rbconfig' + end + ::Config::CONFIG + end + + def initialize(ardir_root, config) + @ardir = File.expand_path(ardir_root) + @config = config + # cache + @valid_task_re = nil + end + + def config(key) + @config[key] + end + + def inspect + "#<#{self.class} #{__id__()}>" + end + + def invoke + run_metaconfigs + case task = parsearg_global() + when nil, 'all' + parsearg_config + init_installers + exec_config + exec_setup + exec_install + else + case task + when 'config', 'test' + ; + when 'clean', 'distclean' + @config.load_savefile if File.exist?(@config.savefile) + else + @config.load_savefile + end + __send__ "parsearg_#{task}" + init_installers + __send__ "exec_#{task}" + end + end + + def run_metaconfigs + @config.load_script "#{@ardir}/metaconfig" + end + + def init_installers + @installer = Installer.new(@config, @ardir, File.expand_path('.')) + end + + # + # Hook Script API bases + # + + def srcdir_root + @ardir + end + + def objdir_root + '.' + end + + def relpath + '.' + end + + # + # Option Parsing + # + + def parsearg_global + while arg = ARGV.shift + case arg + when /\A\w+\z/ + setup_rb_error "invalid task: #{arg}" unless valid_task?(arg) + return arg + when '-q', '--quiet' + @config.verbose = false + when '--verbose' + @config.verbose = true + when '--help' + print_usage $stdout + exit 0 + when '--version' + puts "#{File.basename($0)} version #{Version}" + exit 0 + when '--copyright' + puts Copyright + exit 0 + else + setup_rb_error "unknown global option '#{arg}'" + end + end + nil + end + + def valid_task?(t) + valid_task_re() =~ t + end + + def valid_task_re + @valid_task_re ||= /\A(?:#{TASKS.map {|task,desc| task }.join('|')})\z/ + end + + def parsearg_no_options + unless ARGV.empty? + task = caller(0).first.slice(%r<`parsearg_(\w+)'>, 1) + setup_rb_error "#{task}: unknown options: #{ARGV.join(' ')}" + end + end + + alias parsearg_show parsearg_no_options + alias parsearg_setup parsearg_no_options + alias parsearg_test parsearg_no_options + alias parsearg_clean parsearg_no_options + alias parsearg_distclean parsearg_no_options + + def parsearg_config + evalopt = [] + set = [] + @config.config_opt = [] + while i = ARGV.shift + if /\A--?\z/ =~ i + @config.config_opt = ARGV.dup + break + end + name, value = *@config.parse_opt(i) + if @config.value_config?(name) + @config[name] = value + else + evalopt.push [name, value] + end + set.push name + end + evalopt.each do |name, value| + @config.lookup(name).evaluate value, @config + end + # Check if configuration is valid + set.each do |n| + @config[n] if @config.value_config?(n) + end + end + + def parsearg_install + @config.no_harm = false + @config.install_prefix = '' + while a = ARGV.shift + case a + when '--no-harm' + @config.no_harm = true + when /\A--prefix=/ + path = a.split(/=/, 2)[1] + path = File.expand_path(path) unless path[0,1] == '/' + @config.install_prefix = path + else + setup_rb_error "install: unknown option #{a}" + end + end + end + + def print_usage(out) + out.puts 'Typical Installation Procedure:' + out.puts " $ ruby #{File.basename $0} config" + out.puts " $ ruby #{File.basename $0} setup" + out.puts " # ruby #{File.basename $0} install (may require root privilege)" + out.puts + out.puts 'Detailed Usage:' + out.puts " ruby #{File.basename $0} <global option>" + out.puts " ruby #{File.basename $0} [<global options>] <task> [<task options>]" + + fmt = " %-24s %s\n" + out.puts + out.puts 'Global options:' + out.printf fmt, '-q,--quiet', 'suppress message outputs' + out.printf fmt, ' --verbose', 'output messages verbosely' + out.printf fmt, ' --help', 'print this message' + out.printf fmt, ' --version', 'print version and quit' + out.printf fmt, ' --copyright', 'print copyright and quit' + out.puts + out.puts 'Tasks:' + TASKS.each do |name, desc| + out.printf fmt, name, desc + end + + fmt = " %-24s %s [%s]\n" + out.puts + out.puts 'Options for CONFIG or ALL:' + @config.each do |item| + out.printf fmt, item.help_opt, item.description, item.help_default + end + out.printf fmt, '--rbconfig=path', 'rbconfig.rb to load',"running ruby's" + out.puts + out.puts 'Options for INSTALL:' + out.printf fmt, '--no-harm', 'only display what to do if given', 'off' + out.printf fmt, '--prefix=path', 'install path prefix', '' + out.puts + end + + # + # Task Handlers + # + + def exec_config + @installer.exec_config + @config.save # must be final + end + + def exec_setup + @installer.exec_setup + end + + def exec_install + @installer.exec_install + end + + def exec_test + @installer.exec_test + end + + def exec_show + @config.each do |i| + printf "%-20s %s\n", i.name, i.value if i.value? + end + end + + def exec_clean + @installer.exec_clean + end + + def exec_distclean + @installer.exec_distclean + end + +end # class ToplevelInstaller + + +class ToplevelInstallerMulti < ToplevelInstaller + + include FileOperations + + def initialize(ardir_root, config) + super + @packages = directories_of("#{@ardir}/packages") + raise 'no package exists' if @packages.empty? + @root_installer = Installer.new(@config, @ardir, File.expand_path('.')) + end + + def run_metaconfigs + @config.load_script "#{@ardir}/metaconfig", self + @packages.each do |name| + @config.load_script "#{@ardir}/packages/#{name}/metaconfig" + end + end + + attr_reader :packages + + def packages=(list) + raise 'package list is empty' if list.empty? + list.each do |name| + raise "directory packages/#{name} does not exist"\ + unless File.dir?("#{@ardir}/packages/#{name}") + end + @packages = list + end + + def init_installers + @installers = {} + @packages.each do |pack| + @installers[pack] = Installer.new(@config, + "#{@ardir}/packages/#{pack}", + "packages/#{pack}") + end + with = extract_selection(config('with')) + without = extract_selection(config('without')) + @selected = @installers.keys.select {|name| + (with.empty? or with.include?(name)) \ + and not without.include?(name) + } + end + + def extract_selection(list) + a = list.split(/,/) + a.each do |name| + setup_rb_error "no such package: #{name}" unless @installers.key?(name) + end + a + end + + def print_usage(f) + super + f.puts 'Inluded packages:' + f.puts ' ' + @packages.sort.join(' ') + f.puts + end + + # + # Task Handlers + # + + def exec_config + run_hook 'pre-config' + each_selected_installers {|inst| inst.exec_config } + run_hook 'post-config' + @config.save # must be final + end + + def exec_setup + run_hook 'pre-setup' + each_selected_installers {|inst| inst.exec_setup } + run_hook 'post-setup' + end + + def exec_install + run_hook 'pre-install' + each_selected_installers {|inst| inst.exec_install } + run_hook 'post-install' + end + + def exec_test + run_hook 'pre-test' + each_selected_installers {|inst| inst.exec_test } + run_hook 'post-test' + end + + def exec_clean + rm_f @config.savefile + run_hook 'pre-clean' + each_selected_installers {|inst| inst.exec_clean } + run_hook 'post-clean' + end + + def exec_distclean + rm_f @config.savefile + run_hook 'pre-distclean' + each_selected_installers {|inst| inst.exec_distclean } + run_hook 'post-distclean' + end + + # + # lib + # + + def each_selected_installers + Dir.mkdir 'packages' unless File.dir?('packages') + @selected.each do |pack| + $stderr.puts "Processing the package `#{pack}' ..." if verbose? + Dir.mkdir "packages/#{pack}" unless File.dir?("packages/#{pack}") + Dir.chdir "packages/#{pack}" + yield @installers[pack] + Dir.chdir '../..' + end + end + + def run_hook(id) + @root_installer.run_hook id + end + + # module FileOperations requires this + def verbose? + @config.verbose? + end + + # module FileOperations requires this + def no_harm? + @config.no_harm? + end + +end # class ToplevelInstallerMulti + + +class Installer + + FILETYPES = %w( bin lib ext data conf man ) + + include FileOperations + include HookScriptAPI + + def initialize(config, srcroot, objroot) + @config = config + @srcdir = File.expand_path(srcroot) + @objdir = File.expand_path(objroot) + @currdir = '.' + end + + def inspect + "#<#{self.class} #{File.basename(@srcdir)}>" + end + + def noop(rel) + end + + # + # Hook Script API base methods + # + + def srcdir_root + @srcdir + end + + def objdir_root + @objdir + end + + def relpath + @currdir + end + + # + # Config Access + # + + # module FileOperations requires this + def verbose? + @config.verbose? + end + + # module FileOperations requires this + def no_harm? + @config.no_harm? + end + + def verbose_off + begin + save, @config.verbose = @config.verbose?, false + yield + ensure + @config.verbose = save + end + end + + # + # TASK config + # + + def exec_config + exec_task_traverse 'config' + end + + alias config_dir_bin noop + alias config_dir_lib noop + + def config_dir_ext(rel) + extconf if extdir?(curr_srcdir()) + end + + alias config_dir_data noop + alias config_dir_conf noop + alias config_dir_man noop + + def extconf + ruby "#{curr_srcdir()}/extconf.rb", *@config.config_opt + end + + # + # TASK setup + # + + def exec_setup + exec_task_traverse 'setup' + end + + def setup_dir_bin(rel) + files_of(curr_srcdir()).each do |fname| + update_shebang_line "#{curr_srcdir()}/#{fname}" + end + end + + alias setup_dir_lib noop + + def setup_dir_ext(rel) + make if extdir?(curr_srcdir()) + end + + alias setup_dir_data noop + alias setup_dir_conf noop + alias setup_dir_man noop + + def update_shebang_line(path) + return if no_harm? + return if config('shebang') == 'never' + old = Shebang.load(path) + if old + $stderr.puts "warning: #{path}: Shebang line includes too many args. It is not portable and your program may not work." if old.args.size > 1 + new = new_shebang(old) + return if new.to_s == old.to_s + else + return unless config('shebang') == 'all' + new = Shebang.new(config('rubypath')) + end + $stderr.puts "updating shebang: #{File.basename(path)}" if verbose? + open_atomic_writer(path) {|output| + File.open(path, 'rb') {|f| + f.gets if old # discard + output.puts new.to_s + output.print f.read + } + } + end + + def new_shebang(old) + if /\Aruby/ =~ File.basename(old.cmd) + Shebang.new(config('rubypath'), old.args) + elsif File.basename(old.cmd) == 'env' and old.args.first == 'ruby' + Shebang.new(config('rubypath'), old.args[1..-1]) + else + return old unless config('shebang') == 'all' + Shebang.new(config('rubypath')) + end + end + + def open_atomic_writer(path, &block) + tmpfile = File.basename(path) + '.tmp' + begin + File.open(tmpfile, 'wb', &block) + File.rename tmpfile, File.basename(path) + ensure + File.unlink tmpfile if File.exist?(tmpfile) + end + end + + class Shebang + def Shebang.load(path) + line = nil + File.open(path) {|f| + line = f.gets + } + return nil unless /\A#!/ =~ line + parse(line) + end + + def Shebang.parse(line) + cmd, *args = *line.strip.sub(/\A\#!/, '').split(' ') + new(cmd, args) + end + + def initialize(cmd, args = []) + @cmd = cmd + @args = args + end + + attr_reader :cmd + attr_reader :args + + def to_s + "#! #{@cmd}" + (@args.empty? ? '' : " #{@args.join(' ')}") + end + end + + # + # TASK install + # + + def exec_install + rm_f 'InstalledFiles' + exec_task_traverse 'install' + end + + def install_dir_bin(rel) + install_files targetfiles(), "#{config('bindir')}/#{rel}", 0755 + end + + def install_dir_lib(rel) + install_files libfiles(), "#{config('rbdir')}/#{rel}", 0644 + end + + def install_dir_ext(rel) + return unless extdir?(curr_srcdir()) + install_files rubyextentions('.'), + "#{config('sodir')}/#{File.dirname(rel)}", + 0555 + end + + def install_dir_data(rel) + install_files targetfiles(), "#{config('datadir')}/#{rel}", 0644 + end + + def install_dir_conf(rel) + # FIXME: should not remove current config files + # (rename previous file to .old/.org) + install_files targetfiles(), "#{config('sysconfdir')}/#{rel}", 0644 + end + + def install_dir_man(rel) + install_files targetfiles(), "#{config('mandir')}/#{rel}", 0644 + end + + def install_files(list, dest, mode) + mkdir_p dest, @config.install_prefix + list.each do |fname| + install fname, dest, mode, @config.install_prefix + end + end + + def libfiles + glob_reject(%w(*.y *.output), targetfiles()) + end + + def rubyextentions(dir) + ents = glob_select("*.#{@config.dllext}", targetfiles()) + if ents.empty? + setup_rb_error "no ruby extention exists: 'ruby #{$0} setup' first" + end + ents + end + + def targetfiles + mapdir(existfiles() - hookfiles()) + end + + def mapdir(ents) + ents.map {|ent| + if File.exist?(ent) + then ent # objdir + else "#{curr_srcdir()}/#{ent}" # srcdir + end + } + end + + # picked up many entries from cvs-1.11.1/src/ignore.c + JUNK_FILES = %w( + core RCSLOG tags TAGS .make.state + .nse_depinfo #* .#* cvslog.* ,* .del-* *.olb + *~ *.old *.bak *.BAK *.orig *.rej _$* *$ + + *.org *.in .* + ) + + def existfiles + glob_reject(JUNK_FILES, (files_of(curr_srcdir()) | files_of('.'))) + end + + def hookfiles + %w( pre-%s post-%s pre-%s.rb post-%s.rb ).map {|fmt| + %w( config setup install clean ).map {|t| sprintf(fmt, t) } + }.flatten + end + + def glob_select(pat, ents) + re = globs2re([pat]) + ents.select {|ent| re =~ ent } + end + + def glob_reject(pats, ents) + re = globs2re(pats) + ents.reject {|ent| re =~ ent } + end + + GLOB2REGEX = { + '.' => '\.', + '$' => '\$', + '#' => '\#', + '*' => '.*' + } + + def globs2re(pats) + /\A(?:#{ + pats.map {|pat| pat.gsub(/[\.\$\#\*]/) {|ch| GLOB2REGEX[ch] } }.join('|') + })\z/ + end + + # + # TASK test + # + + TESTDIR = 'test' + + def exec_test + unless File.directory?('test') + $stderr.puts 'no test in this package' if verbose? + return + end + $stderr.puts 'Running tests...' if verbose? + begin + require 'test/unit' + rescue LoadError + setup_rb_error 'test/unit cannot loaded. You need Ruby 1.8 or later to invoke this task.' + end + runner = Test::Unit::AutoRunner.new(true) + runner.to_run << TESTDIR + runner.run + end + + # + # TASK clean + # + + def exec_clean + exec_task_traverse 'clean' + rm_f @config.savefile + rm_f 'InstalledFiles' + end + + alias clean_dir_bin noop + alias clean_dir_lib noop + alias clean_dir_data noop + alias clean_dir_conf noop + alias clean_dir_man noop + + def clean_dir_ext(rel) + return unless extdir?(curr_srcdir()) + make 'clean' if File.file?('Makefile') + end + + # + # TASK distclean + # + + def exec_distclean + exec_task_traverse 'distclean' + rm_f @config.savefile + rm_f 'InstalledFiles' + end + + alias distclean_dir_bin noop + alias distclean_dir_lib noop + + def distclean_dir_ext(rel) + return unless extdir?(curr_srcdir()) + make 'distclean' if File.file?('Makefile') + end + + alias distclean_dir_data noop + alias distclean_dir_conf noop + alias distclean_dir_man noop + + # + # Traversing + # + + def exec_task_traverse(task) + run_hook "pre-#{task}" + FILETYPES.each do |type| + if type == 'ext' and config('without-ext') == 'yes' + $stderr.puts 'skipping ext/* by user option' if verbose? + next + end + traverse task, type, "#{task}_dir_#{type}" + end + run_hook "post-#{task}" + end + + def traverse(task, rel, mid) + dive_into(rel) { + run_hook "pre-#{task}" + __send__ mid, rel.sub(%r[\A.*?(?:/|\z)], '') + directories_of(curr_srcdir()).each do |d| + traverse task, "#{rel}/#{d}", mid + end + run_hook "post-#{task}" + } + end + + def dive_into(rel) + return unless File.dir?("#{@srcdir}/#{rel}") + + dir = File.basename(rel) + Dir.mkdir dir unless File.dir?(dir) + prevdir = Dir.pwd + Dir.chdir dir + $stderr.puts '---> ' + rel if verbose? + @currdir = rel + yield + Dir.chdir prevdir + $stderr.puts '<--- ' + rel if verbose? + @currdir = File.dirname(rel) + end + + def run_hook(id) + path = [ "#{curr_srcdir()}/#{id}", + "#{curr_srcdir()}/#{id}.rb" ].detect {|cand| File.file?(cand) } + return unless path + begin + instance_eval File.read(path), path, 1 + rescue + raise if $DEBUG + setup_rb_error "hook #{path} failed:\n" + $!.message + end + end + +end # class Installer + + +class SetupError < StandardError; end + +def setup_rb_error(msg) + raise SetupError, msg +end + +if $0 == __FILE__ + begin + ToplevelInstaller.invoke + rescue SetupError + raise if $DEBUG + $stderr.puts $!.message + $stderr.puts "Try 'ruby #{$0} --help' for detailed usage." + exit 1 + end +end diff --git a/test/test_clogger.rb b/test/test_clogger.rb new file mode 100644 index 0000000..f79017b --- /dev/null +++ b/test/test_clogger.rb @@ -0,0 +1,349 @@ +# -*- encoding: binary -*- +$stderr.sync = $stdout.sync = true +require "test/unit" +require "date" +require "stringio" + +require "rack" + +require "clogger" +class TestClogger < Test::Unit::TestCase + include Clogger::Format + + def setup + @req = { + "REQUEST_METHOD" => "GET", + "HTTP_VERSION" => "HTTP/1.0", + "HTTP_USER_AGENT" => 'echo and socat \o/', + "PATH_INFO" => "/hello", + "QUERY_STRING" => "goodbye=true", + "rack.errors" => $stderr, + "rack.input" => File.open('/dev/null', 'rb'), + "REMOTE_ADDR" => 'home', + } + end + + def test_init_basic + Clogger.new(lambda { |env| [ 0, {}, [] ] }) + end + + def test_init_noargs + assert_raise(ArgumentError) { Clogger.new } + end + + def test_init_stderr + cl = Clogger.new(lambda { |env| [ 0, {}, [] ] }, :logger => $stderr) + assert_kind_of(Integer, cl.fileno) + assert_equal $stderr.fileno, cl.fileno + end + + def test_init_stringio + cl = Clogger.new(lambda { |env| [ 0, {}, [] ] }, :logger => StringIO.new) + assert_nil cl.fileno + end + + def test_write_stringio + start = DateTime.now - 1 + str = StringIO.new + cl = Clogger.new(lambda { |env| [ "302 Found", {}, [] ] }, :logger => str) + status, headers, body = cl.call(@req) + assert_equal("302 Found", status) + assert_equal({}, headers) + body.each { |part| assert false } + str = str.string + r = %r{\Ahome - - \[[^\]]+\] "GET /hello\?goodbye=true HTTP/1.0" 302 -\n\z} + assert_match r, str + %r{\[([^\]]+)\]} =~ str + tmp = nil + assert_nothing_raised { + tmp = DateTime.strptime($1, "%d/%b/%Y:%H:%M:%S %z") + } + assert tmp >= start + assert tmp <= DateTime.now + end + + def test_clen_stringio + start = DateTime.now - 1 + str = StringIO.new + app = lambda { |env| [ 301, {'Content-Length' => '5'}, ['abcde'] ] } + format = Common.dup + assert format.gsub!(/response_length/, 'sent_http_content_length') + cl = Clogger.new(app, :logger => str, :format => format) + status, headers, body = cl.call(@req) + assert_equal(301, status) + assert_equal({'Content-Length' => '5'}, headers) + body.each { |part| assert_equal('abcde', part) } + str = str.string + r = %r{\Ahome - - \[[^\]]+\] "GET /hello\?goodbye=true HTTP/1.0" 301 5\n\z} + assert_match r, str + %r{\[([^\]]+)\]} =~ str + tmp = nil + assert_nothing_raised { + tmp = DateTime.strptime($1, "%d/%b/%Y:%H:%M:%S %z") + } + assert tmp >= start + assert tmp <= DateTime.now + end + + def test_compile_ambiguous + cl = Clogger.new(nil, :logger => $stderr) + ary = nil + cl.instance_eval { + ary = compile_format( + '$remote_addr $$$$pid' \ + "\n") + } + expect = [ + [ Clogger::OP_REQUEST, "REMOTE_ADDR" ], + [ Clogger::OP_LITERAL, " " ], + [ Clogger::OP_LITERAL, "$$$" ], + [ Clogger::OP_SPECIAL, Clogger::SPECIAL_VARS[:pid] ], + [ Clogger::OP_LITERAL, "\n" ], + ] + assert_equal expect, ary + end + + def test_compile_auto_newline + cl = Clogger.new(nil, :logger => $stderr) + ary = nil + cl.instance_eval { ary = compile_format('$remote_addr $request') } + expect = [ + [ Clogger::OP_REQUEST, "REMOTE_ADDR" ], + [ Clogger::OP_LITERAL, " " ], + [ Clogger::OP_SPECIAL, Clogger::SPECIAL_VARS[:request] ], + [ Clogger::OP_LITERAL, "\n" ], + ] + assert_equal expect, ary + end + + def test_big_log + str = StringIO.new + fmt = '$remote_addr $pid $remote_user [$time_local] ' \ + '"$request" $status $body_bytes_sent "$http_referer" ' \ + '"$http_user_agent" "$http_cookie" $request_time $http_host' + app = lambda { |env| [ 302, {}, [] ] } + cl = Clogger.new(app, :logger => str, :format => fmt) + cookie = "foo=bar#{'f' * 256}".freeze + req = { + 'HTTP_HOST' => 'example.com:12345', + 'HTTP_COOKIE' => cookie, + } + req = @req.merge(req) + cl.call(req).last.each { |part| part } + str = str.string + assert(str.size > 128) + assert_match %r["echo and socat \\o/" "#{cookie}" \d+\.\d{3}], str + assert_match %r["#{cookie}" \d+\.\d{3} example\.com:12345\n\z], str + end + + def test_compile + cl = Clogger.new(nil, :logger => $stderr) + ary = nil + cl.instance_eval { + ary = compile_format( + '$remote_addr - $remote_user [$time_local] ' \ + '"$request" $status $body_bytes_sent "$http_referer" ' \ + '"$http_user_agent" "$http_cookie" $request_time ' \ + '$env{rack.url_scheme}' \ + "\n") + } + expect = [ + [ Clogger::OP_REQUEST, "REMOTE_ADDR" ], + [ Clogger::OP_LITERAL, " - " ], + [ Clogger::OP_REQUEST, "REMOTE_USER" ], + [ Clogger::OP_LITERAL, " [" ], + [ Clogger::OP_TIME_LOCAL, '%d/%b/%Y:%H:%M:%S %z' ], + [ Clogger::OP_LITERAL, "] \"" ], + [ Clogger::OP_SPECIAL, Clogger::SPECIAL_VARS[:request] ], + [ Clogger::OP_LITERAL, "\" "], + [ Clogger::OP_SPECIAL, Clogger::SPECIAL_VARS[:status] ], + [ Clogger::OP_LITERAL, " "], + [ Clogger::OP_SPECIAL, Clogger::SPECIAL_VARS[:body_bytes_sent] ], + [ Clogger::OP_LITERAL, " \"" ], + [ Clogger::OP_REQUEST, "HTTP_REFERER" ], + [ Clogger::OP_LITERAL, "\" \"" ], + [ Clogger::OP_REQUEST, "HTTP_USER_AGENT" ], + [ Clogger::OP_LITERAL, "\" \"" ], + [ Clogger::OP_REQUEST, "HTTP_COOKIE" ], + [ Clogger::OP_LITERAL, "\" " ], + [ Clogger::OP_REQUEST_TIME, '%d.%03d', 1000 ], + [ Clogger::OP_LITERAL, " " ], + [ Clogger::OP_REQUEST, "rack.url_scheme" ], + [ Clogger::OP_LITERAL, "\n" ], + ] + assert_equal expect, ary + end + + def test_eval + current = Thread.current.to_s + str = StringIO.new + app = lambda { |env| [ 302, {}, [] ] } + cl = Clogger.new(app, + :logger => str, + :format => "-$e{Thread.current}-\n") + status, headers, body = cl.call(@req) + assert_equal "-#{current}-\n", str.string + end + + def test_pid + str = StringIO.new + app = lambda { |env| [ 302, {}, [] ] } + cl = Clogger.new(app, :logger => str, :format => "[$pid]\n") + status, headers, body = cl.call(@req) + assert_equal "[#$$]\n", str.string + end + + def test_rack_xff + str = StringIO.new + app = lambda { |env| [ 302, {}, [] ] } + cl = Clogger.new(app, :logger => str, :format => "$ip") + req = @req.merge("HTTP_X_FORWARDED_FOR" => '192.168.1.1') + status, headers, body = cl.call(req) + assert_equal "192.168.1.1\n", str.string + str.rewind + str.truncate(0) + status, headers, body = cl.call(@req) + assert_equal "home\n", str.string + str.rewind + str.truncate(0) + end + + def test_rack_1_0 + start = DateTime.now - 1 + str = StringIO.new + app = lambda { |env| [ 200, {'Content-Length'=>'0'}, %w(a b c)] } + cl = Clogger.new(app, :logger => str, :format => Rack_1_0) + status, headers, body = cl.call(@req) + tmp = [] + body.each { |s| tmp << s } + assert_equal %w(a b c), tmp + str = str.string + assert_match %r[" 200 3 \d+\.\d{4}\n\z], str + tmp = nil + %r{\[(\d+/\w+/\d+ \d+:\d+:\d+)\]} =~ str + assert $1 + assert_nothing_raised { tmp = DateTime.strptime($1, "%d/%b/%Y %H:%M:%S") } + assert tmp >= start + assert tmp <= DateTime.now + end + + def test_msec + str = StringIO.new + app = lambda { |env| [ 200, {}, [] ] } + cl = Clogger.new(app, :logger => str, :format => '$msec') + status, header, bodies = cl.call(@req) + assert_match %r(\A\d+\.\d{3}\n\z), str.string + end + + def test_usec + str = StringIO.new + app = lambda { |env| [ 200, {}, [] ] } + cl = Clogger.new(app, :logger => str, :format => '$usec') + status, header, bodies = cl.call(@req) + assert_match %r(\A\d+\.\d{6}\n\z), str.string + end + + def test_time_0 + str = StringIO.new + app = lambda { |env| [ 200, {}, [] ] } + cl = Clogger.new(app, :logger => str, :format => '$time{0}') + status, header, bodies = cl.call(@req) + assert_match %r(\A\d+\n\z), str.string + end + + def test_time_1 + str = StringIO.new + app = lambda { |env| [ 200, {}, [] ] } + cl = Clogger.new(app, :logger => str, :format => '$time{1}') + status, header, bodies = cl.call(@req) + assert_match %r(\A\d+\.\d\n\z), str.string + end + + def test_request_length + str = StringIO.new + input = StringIO.new('.....') + app = lambda { |env| [ 200, {}, [] ] } + cl = Clogger.new(app, :logger => str, :format => '$request_length') + status, header, bodies = cl.call(@req.merge('rack.input' => input)) + assert_equal "5\n", str.string + end + + def test_response_length_0 + str = StringIO.new + app = lambda { |env| [ 200, {}, [] ] } + cl = Clogger.new(app, :logger => str, :format => '$response_length') + status, header, bodies = cl.call(@req) + bodies.each { |part| part } + assert_equal "-\n", str.string + end + + def test_combined + start = DateTime.now - 1 + str = StringIO.new + app = lambda { |env| [ 200, {'Content-Length'=>'3'}, %w(a b c)] } + cl = Clogger.new(app, :logger => str, :format => Combined) + status, headers, body = cl.call(@req) + tmp = [] + body.each { |s| tmp << s } + assert_equal %w(a b c), tmp + str = str.string + assert_match %r[" 200 3 "-" "echo and socat \\o/"\n\z], str + tmp = nil + %r{\[(\d+/\w+/\d+:\d+:\d+:\d+ .+)\]} =~ str + assert $1 + assert_nothing_raised { + tmp = DateTime.strptime($1, "%d/%b/%Y:%H:%M:%S %z") + } + assert tmp >= start + assert tmp <= DateTime.now + end + + def test_rack_errors_fallback + err = StringIO.new + app = lambda { |env| [ 200, {'Content-Length'=>'3'}, %w(a b c)] } + cl = Clogger.new(app, :format => '$pid') + req = @req.merge('rack.errors' => err) + status, headers, body = cl.call(req) + assert_equal "#$$\n", err.string + end + + def test_body_close + s_body = StringIO.new(%w(a b c).join("\n")) + app = lambda { |env| [ 200, {'Content-Length'=>'5'}, s_body] } + cl = Clogger.new(app, :logger => [], :format => '$pid') + status, headers, body = cl.call(@req) + assert ! s_body.closed? + assert_nothing_raised { body.close } + assert s_body.closed? + end + + def test_escape + str = StringIO.new + app = lambda { |env| [ 200, {'Content-Length'=>'5'}, [] ] } + cl = Clogger.new(app, + :logger => str, + :format => '$http_user_agent "$request"') + bad = { + 'HTTP_USER_AGENT' => '"asdf"', + 'QUERY_STRING' => 'sdf=bar"', + 'PATH_INFO' => '/"<>"', + } + status, headers, body = cl.call(@req.merge(bad)) + expect = '\x22asdf\x22 "GET /\x22<>\x22?sdf=bar\x22 HTTP/1.0"' << "\n" + assert_equal expect, str.string + end + + def test_cookies + str = StringIO.new + app = lambda { |env| + req = Rack::Request.new(env).cookies + [ 302, {}, [] ] + } + cl = Clogger.new(app, + :format => '$cookie_foo $cookie_quux', + :logger => str) + req = @req.merge('HTTP_COOKIE' => "foo=bar;quux=h&m") + status, headers, body = cl.call(req) + assert_equal "bar h&m\n", str.string + end +end |